diff --git a/.coveragerc b/.coveragerc index bf3dd5f4a00cf1..5b046a7249d011 100644 --- a/.coveragerc +++ b/.coveragerc @@ -45,6 +45,7 @@ omit = homeassistant/components/airthings_ble/sensor.py homeassistant/components/airtouch4/__init__.py homeassistant/components/airtouch4/climate.py + homeassistant/components/airtouch4/coordinator.py homeassistant/components/airvisual/__init__.py homeassistant/components/airvisual/sensor.py homeassistant/components/airvisual_pro/__init__.py @@ -101,6 +102,7 @@ omit = homeassistant/components/azure_devops/__init__.py homeassistant/components/azure_devops/sensor.py homeassistant/components/azure_service_bus/* + homeassistant/components/awair/coordinator.py homeassistant/components/baf/__init__.py homeassistant/components/baf/climate.py homeassistant/components/baf/entity.py @@ -180,6 +182,7 @@ omit = homeassistant/components/control4/__init__.py homeassistant/components/control4/director_utils.py homeassistant/components/control4/light.py + homeassistant/components/coolmaster/coordinator.py homeassistant/components/cppm_tracker/device_tracker.py homeassistant/components/crownstone/__init__.py homeassistant/components/crownstone/devices.py @@ -256,6 +259,10 @@ omit = homeassistant/components/ecobee/notify.py homeassistant/components/ecobee/sensor.py homeassistant/components/ecobee/weather.py + homeassistant/components/ecoforest/__init__.py + homeassistant/components/ecoforest/coordinator.py + homeassistant/components/ecoforest/entity.py + homeassistant/components/ecoforest/sensor.py homeassistant/components/econet/__init__.py homeassistant/components/econet/binary_sensor.py homeassistant/components/econet/climate.py @@ -277,7 +284,6 @@ omit = homeassistant/components/electric_kiwi/__init__.py homeassistant/components/electric_kiwi/api.py homeassistant/components/electric_kiwi/oauth2.py - homeassistant/components/electric_kiwi/sensor.py homeassistant/components/electric_kiwi/coordinator.py homeassistant/components/electric_kiwi/select.py homeassistant/components/eliqonline/sensor.py @@ -356,6 +362,7 @@ omit = homeassistant/components/ezviz/update.py homeassistant/components/faa_delays/__init__.py homeassistant/components/faa_delays/binary_sensor.py + homeassistant/components/faa_delays/coordinator.py homeassistant/components/familyhub/camera.py homeassistant/components/fastdotcom/* homeassistant/components/ffmpeg/camera.py @@ -417,7 +424,6 @@ omit = homeassistant/components/freebox/device_tracker.py homeassistant/components/freebox/home_base.py homeassistant/components/freebox/router.py - homeassistant/components/freebox/sensor.py homeassistant/components/freebox/switch.py homeassistant/components/fritz/common.py homeassistant/components/fritz/device_tracker.py @@ -656,6 +662,7 @@ omit = homeassistant/components/lg_soundbar/__init__.py homeassistant/components/lg_soundbar/media_player.py homeassistant/components/life360/__init__.py + homeassistant/components/life360/button.py homeassistant/components/life360/coordinator.py homeassistant/components/life360/device_tracker.py homeassistant/components/lightwave/* @@ -705,7 +712,8 @@ omit = homeassistant/components/mailgun/notify.py homeassistant/components/map/* homeassistant/components/mastodon/notify.py - homeassistant/components/matrix/* + homeassistant/components/matrix/__init__.py + homeassistant/components/matrix/notify.py homeassistant/components/matter/__init__.py homeassistant/components/meater/__init__.py homeassistant/components/meater/sensor.py @@ -733,6 +741,7 @@ omit = homeassistant/components/mill/sensor.py homeassistant/components/minecraft_server/__init__.py homeassistant/components/minecraft_server/binary_sensor.py + homeassistant/components/minecraft_server/coordinator.py homeassistant/components/minecraft_server/entity.py homeassistant/components/minecraft_server/sensor.py homeassistant/components/minio/minio_helper.py @@ -749,6 +758,7 @@ omit = homeassistant/components/moehlenhoff_alpha2/sensor.py homeassistant/components/motion_blinds/__init__.py homeassistant/components/motion_blinds/cover.py + homeassistant/components/motion_blinds/entity.py homeassistant/components/motion_blinds/sensor.py homeassistant/components/mpd/media_player.py homeassistant/components/mqtt_room/sensor.py @@ -792,6 +802,7 @@ omit = homeassistant/components/netgear/__init__.py homeassistant/components/netgear/button.py homeassistant/components/netgear/device_tracker.py + homeassistant/components/netgear/entity.py homeassistant/components/netgear/router.py homeassistant/components/netgear/sensor.py homeassistant/components/netgear/switch.py @@ -1006,9 +1017,13 @@ omit = homeassistant/components/rainmachine/util.py homeassistant/components/renson/__init__.py homeassistant/components/renson/const.py + homeassistant/components/renson/coordinator.py homeassistant/components/renson/entity.py homeassistant/components/renson/sensor.py + homeassistant/components/renson/button.py + homeassistant/components/renson/fan.py homeassistant/components/renson/binary_sensor.py + homeassistant/components/renson/number.py homeassistant/components/raspyrfm/* homeassistant/components/recollect_waste/sensor.py homeassistant/components/recorder/repack.py @@ -1069,9 +1084,10 @@ omit = homeassistant/components/saj/sensor.py homeassistant/components/satel_integra/* homeassistant/components/schluter/* - homeassistant/components/screenlogic/__init__.py homeassistant/components/screenlogic/binary_sensor.py homeassistant/components/screenlogic/climate.py + homeassistant/components/screenlogic/coordinator.py + homeassistant/components/screenlogic/const.py homeassistant/components/screenlogic/entity.py homeassistant/components/screenlogic/light.py homeassistant/components/screenlogic/number.py @@ -1135,6 +1151,7 @@ omit = homeassistant/components/smarty/* homeassistant/components/sms/__init__.py homeassistant/components/sms/const.py + homeassistant/components/sms/coordinator.py homeassistant/components/sms/gateway.py homeassistant/components/sms/notify.py homeassistant/components/sms/sensor.py @@ -1151,6 +1168,7 @@ omit = homeassistant/components/solaredge_local/sensor.py homeassistant/components/solarlog/__init__.py homeassistant/components/solarlog/sensor.py + homeassistant/components/solarlog/coordinator.py homeassistant/components/solax/__init__.py homeassistant/components/solax/sensor.py homeassistant/components/soma/__init__.py @@ -1243,6 +1261,9 @@ omit = homeassistant/components/switchbot/sensor.py homeassistant/components/switchbot/switch.py homeassistant/components/switchbot/lock.py + homeassistant/components/switchbot_cloud/coordinator.py + homeassistant/components/switchbot_cloud/entity.py + homeassistant/components/switchbot_cloud/switch.py homeassistant/components/switchmate/switch.py homeassistant/components/syncthing/__init__.py homeassistant/components/syncthing/sensor.py @@ -1265,6 +1286,7 @@ omit = homeassistant/components/system_bridge/__init__.py homeassistant/components/system_bridge/binary_sensor.py homeassistant/components/system_bridge/coordinator.py + homeassistant/components/system_bridge/notify.py homeassistant/components/system_bridge/sensor.py homeassistant/components/systemmonitor/sensor.py homeassistant/components/tado/__init__.py @@ -1449,6 +1471,7 @@ omit = homeassistant/components/vodafone_station/const.py homeassistant/components/vodafone_station/coordinator.py homeassistant/components/vodafone_station/device_tracker.py + homeassistant/components/vodafone_station/sensor.py homeassistant/components/volkszaehler/sensor.py homeassistant/components/volumio/__init__.py homeassistant/components/volumio/browse_media.py @@ -1474,6 +1497,7 @@ omit = homeassistant/components/wiffi/sensor.py homeassistant/components/wiffi/wiffi_strings.py homeassistant/components/wirelesstag/* + homeassistant/components/withings/api.py homeassistant/components/wolflink/__init__.py homeassistant/components/wolflink/sensor.py homeassistant/components/worldtidesinfo/sensor.py diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 3296f33f84c595..0b0983a001f2af 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -24,7 +24,7 @@ jobs: publish: ${{ steps.version.outputs.publish }} steps: - name: Checkout the repository - uses: actions/checkout@v3.6.0 + uses: actions/checkout@v4.0.0 with: fetch-depth: 0 @@ -56,7 +56,7 @@ jobs: if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true' steps: - name: Checkout the repository - uses: actions/checkout@v3.6.0 + uses: actions/checkout@v4.0.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.7.0 @@ -98,7 +98,7 @@ jobs: arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository - uses: actions/checkout@v3.6.0 + uses: actions/checkout@v4.0.0 - name: Download nightly wheels of frontend if: needs.init.outputs.channel == 'dev' @@ -190,7 +190,7 @@ jobs: echo "${{ github.sha }};${{ github.ref }};${{ github.event_name }};${{ github.actor }}" > rootfs/OFFICIAL_IMAGE - name: Login to GitHub Container Registry - uses: docker/login-action@v2.2.0 + uses: docker/login-action@v3.0.0 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -254,7 +254,7 @@ jobs: - green steps: - name: Checkout the repository - uses: actions/checkout@v3.6.0 + uses: actions/checkout@v4.0.0 - name: Set build additional args run: | @@ -268,7 +268,7 @@ jobs: fi - name: Login to GitHub Container Registry - uses: docker/login-action@v2.2.0 + uses: docker/login-action@v3.0.0 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -293,7 +293,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout the repository - uses: actions/checkout@v3.6.0 + uses: actions/checkout@v4.0.0 - name: Initialize git uses: home-assistant/actions/helpers/git-init@master @@ -331,21 +331,21 @@ jobs: id-token: write steps: - name: Checkout the repository - uses: actions/checkout@v3.6.0 + uses: actions/checkout@v4.0.0 - name: Install Cosign - uses: sigstore/cosign-installer@v3.1.1 + uses: sigstore/cosign-installer@v3.1.2 with: cosign-release: "v2.0.2" - name: Login to DockerHub - uses: docker/login-action@v2.2.0 + uses: docker/login-action@v3.0.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to GitHub Container Registry - uses: docker/login-action@v2.2.0 + uses: docker/login-action@v3.0.0 with: registry: ghcr.io username: ${{ github.repository_owner }} diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index bf6ba38ea91e48..2ac6773b6e906b 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -36,6 +36,7 @@ env: CACHE_VERSION: 5 PIP_CACHE_VERSION: 4 MYPY_CACHE_VERSION: 4 + BLACK_CACHE_VERSION: 1 HA_SHORT_VERSION: "2023.10" DEFAULT_PYTHON: "3.11" ALL_PYTHON_VERSIONS: "['3.11']" @@ -55,6 +56,7 @@ env: POSTGRESQL_VERSIONS: "['postgres:12.14','postgres:15.2']" PRE_COMMIT_CACHE: ~/.cache/pre-commit PIP_CACHE: /tmp/pip-cache + BLACK_CACHE: /tmp/black-cache SQLALCHEMY_WARN_20: 1 PYTHONASYNCIODEBUG: 1 HASS_CI: 1 @@ -87,7 +89,7 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Check out code from GitHub - uses: actions/checkout@v3.6.0 + uses: actions/checkout@v4.0.0 - name: Generate partial Python venv restore key id: generate_python_cache_key run: >- @@ -220,7 +222,7 @@ jobs: - info steps: - name: Check out code from GitHub - uses: actions/checkout@v3.6.0 + uses: actions/checkout@v4.0.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.7.0 @@ -229,7 +231,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v3.3.1 + uses: actions/cache@v3.3.2 with: path: venv key: >- @@ -244,7 +246,7 @@ jobs: pip install "$(cat requirements_test.txt | grep pre-commit)" - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache@v3.3.1 + uses: actions/cache@v3.3.2 with: path: ${{ env.PRE_COMMIT_CACHE }} lookup-only: true @@ -265,16 +267,23 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v3.6.0 + uses: actions/checkout@v4.0.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.7.0 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true + - name: Generate partial black restore key + id: generate-black-key + run: | + black_version=$(cat requirements_test_pre_commit.txt | grep black | cut -d '=' -f 3) + echo "version=$black_version" >> $GITHUB_OUTPUT + echo "key=black-${{ env.BLACK_CACHE_VERSION }}-$black_version-${{ + env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.1 + uses: actions/cache/restore@v3.3.2 with: path: venv fail-on-cache-miss: true @@ -283,21 +292,36 @@ jobs: needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache/restore@v3.3.1 + uses: actions/cache/restore@v3.3.2 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true key: >- ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.pre-commit_cache_key }} + - name: Restore black cache + uses: actions/cache@v3.3.2 + with: + path: ${{ env.BLACK_CACHE }} + key: >- + ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ + steps.generate-black-key.outputs.key }} + restore-keys: | + ${{ runner.os }}-${{ steps.python.outputs.python-version }}-black-${{ + env.BLACK_CACHE_VERSION }}-${{ steps.generate-black-key.outputs.version }}-${{ + env.HA_SHORT_VERSION }}- - name: Run black (fully) if: needs.info.outputs.test_full_suite == 'true' + env: + BLACK_CACHE_DIR: ${{ env.BLACK_CACHE }} run: | . venv/bin/activate pre-commit run --hook-stage manual black --all-files --show-diff-on-failure - name: Run black (partially) if: needs.info.outputs.test_full_suite == 'false' shell: bash + env: + BLACK_CACHE_DIR: ${{ env.BLACK_CACHE }} run: | . venv/bin/activate shopt -s globstar @@ -311,7 +335,7 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v3.6.0 + uses: actions/checkout@v4.0.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.7.0 id: python @@ -320,7 +344,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.1 + uses: actions/cache/restore@v3.3.2 with: path: venv fail-on-cache-miss: true @@ -329,7 +353,7 @@ jobs: needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache/restore@v3.3.1 + uses: actions/cache/restore@v3.3.2 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -360,7 +384,7 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v3.6.0 + uses: actions/checkout@v4.0.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.7.0 id: python @@ -369,7 +393,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.1 + uses: actions/cache/restore@v3.3.2 with: path: venv fail-on-cache-miss: true @@ -378,7 +402,7 @@ jobs: needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache/restore@v3.3.1 + uses: actions/cache/restore@v3.3.2 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -454,7 +478,7 @@ jobs: python-version: ${{ fromJSON(needs.info.outputs.python_versions) }} steps: - name: Check out code from GitHub - uses: actions/checkout@v3.6.0 + uses: actions/checkout@v4.0.0 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v4.7.0 @@ -468,7 +492,7 @@ jobs: env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v3.3.1 + uses: actions/cache@v3.3.2 with: path: venv lookup-only: true @@ -477,7 +501,7 @@ jobs: needs.info.outputs.python_cache_key }} - name: Restore pip wheel cache if: steps.cache-venv.outputs.cache-hit != 'true' - uses: actions/cache@v3.3.1 + uses: actions/cache@v3.3.2 with: path: ${{ env.PIP_CACHE }} key: >- @@ -522,7 +546,7 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v3.6.0 + uses: actions/checkout@v4.0.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.7.0 @@ -531,7 +555,7 @@ jobs: check-latest: true - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.1 + uses: actions/cache/restore@v3.3.2 with: path: venv fail-on-cache-miss: true @@ -554,7 +578,7 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v3.6.0 + uses: actions/checkout@v4.0.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.7.0 @@ -563,7 +587,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.1 + uses: actions/cache/restore@v3.3.2 with: path: venv fail-on-cache-miss: true @@ -587,7 +611,7 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v3.6.0 + uses: actions/checkout@v4.0.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.7.0 @@ -596,7 +620,7 @@ jobs: check-latest: true - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.1 + uses: actions/cache/restore@v3.3.2 with: path: venv fail-on-cache-miss: true @@ -631,7 +655,7 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v3.6.0 + uses: actions/checkout@v4.0.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.7.0 @@ -647,7 +671,7 @@ jobs: env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.1 + uses: actions/cache/restore@v3.3.2 with: path: venv fail-on-cache-miss: true @@ -655,7 +679,7 @@ jobs: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.python_cache_key }} - name: Restore mypy cache - uses: actions/cache@v3.3.1 + uses: actions/cache@v3.3.2 with: path: .mypy_cache key: >- @@ -713,7 +737,7 @@ jobs: bluez \ ffmpeg - name: Check out code from GitHub - uses: actions/checkout@v3.6.0 + uses: actions/checkout@v4.0.0 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v4.7.0 @@ -722,7 +746,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.1 + uses: actions/cache/restore@v3.3.2 with: path: venv fail-on-cache-miss: true @@ -865,7 +889,7 @@ jobs: ffmpeg \ libmariadb-dev-compat - name: Check out code from GitHub - uses: actions/checkout@v3.6.0 + uses: actions/checkout@v4.0.0 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v4.7.0 @@ -874,7 +898,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.1 + uses: actions/cache/restore@v3.3.2 with: path: venv fail-on-cache-miss: true @@ -989,7 +1013,7 @@ jobs: ffmpeg \ postgresql-server-dev-14 - name: Check out code from GitHub - uses: actions/checkout@v3.6.0 + uses: actions/checkout@v4.0.0 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v4.7.0 @@ -998,7 +1022,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.1 + uses: actions/cache/restore@v3.3.2 with: path: venv fail-on-cache-miss: true @@ -1084,7 +1108,7 @@ jobs: timeout-minutes: 10 steps: - name: Check out code from GitHub - uses: actions/checkout@v3.6.0 + uses: actions/checkout@v4.0.0 - name: Download all coverage artifacts uses: actions/download-artifact@v3 - name: Upload coverage to Codecov (full coverage) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 5fb977f74d1ef1..c91117cb02d352 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -42,7 +42,7 @@ jobs: id: token # Pinned to a specific version of the action for security reasons # v1.7.0 - uses: tibdex/github-app-token@b62528385c34dbc9f38e5f4225ac829252d1ea92 + uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a with: app_id: ${{ secrets.ISSUE_TRIAGE_APP_ID }} private_key: ${{ secrets.ISSUE_TRIAGE_APP_PEM }} diff --git a/.github/workflows/translations.yml b/.github/workflows/translations.yml index 5affa459f52d54..a98c4d99734fce 100644 --- a/.github/workflows/translations.yml +++ b/.github/workflows/translations.yml @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout the repository - uses: actions/checkout@v3.6.0 + uses: actions/checkout@v4.0.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.7.0 diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 01823199c17cc0..7636d628e411f0 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -26,7 +26,7 @@ jobs: architectures: ${{ steps.info.outputs.architectures }} steps: - name: Checkout the repository - uses: actions/checkout@v3.6.0 + uses: actions/checkout@v4.0.0 - name: Get information id: info @@ -56,7 +56,7 @@ jobs: echo "CI_BUILD=1" echo "ENABLE_HEADLESS=1" - # Use C-Extension for sqlalchemy + # Use C-Extension for SQLAlchemy echo "REQUIRE_SQLALCHEMY_CEXT=1" ) > .env_file @@ -84,7 +84,7 @@ jobs: arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository - uses: actions/checkout@v3.6.0 + uses: actions/checkout@v4.0.0 - name: Download env_file uses: actions/download-artifact@v3 @@ -122,7 +122,7 @@ jobs: arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository - uses: actions/checkout@v3.6.0 + uses: actions/checkout@v4.0.0 - name: Download env_file uses: actions/download-artifact@v3 @@ -186,7 +186,7 @@ jobs: wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" - skip-binary: aiohttp;grpcio;sqlalchemy;protobuf + skip-binary: aiohttp;grpcio;SQLAlchemy;protobuf constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" requirements: "requirements_all.txtaa" @@ -200,7 +200,7 @@ jobs: wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" - skip-binary: aiohttp;grpcio;sqlalchemy;protobuf + skip-binary: aiohttp;grpcio;SQLAlchemy;protobuf constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" requirements: "requirements_all.txtab" @@ -214,7 +214,7 @@ jobs: wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" - skip-binary: aiohttp;grpcio;sqlalchemy;protobuf + skip-binary: aiohttp;grpcio;SQLAlchemy;protobuf constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" requirements: "requirements_all.txtac" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 77740d6279e72b..b0c981433004cf 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,12 +1,12 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.0.285 + rev: v0.0.289 hooks: - id: ruff args: - --fix - repo: https://github.com/psf/black-pre-commit-mirror - rev: 23.7.0 + rev: 23.9.1 hooks: - id: black args: @@ -21,7 +21,7 @@ repos: - --skip="./.*,*.csv,*.json,*.ambr" - --quiet-level=2 exclude_types: [csv, json] - exclude: ^tests/fixtures/|homeassistant/generated/ + exclude: ^tests/fixtures/|homeassistant/generated/|tests/components/.*/snapshots/ - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.4.0 hooks: diff --git a/.strict-typing b/.strict-typing index e8bca0a1abd181..97af46884c4f8d 100644 --- a/.strict-typing +++ b/.strict-typing @@ -88,6 +88,7 @@ homeassistant.components.camera.* homeassistant.components.canary.* homeassistant.components.clickatell.* homeassistant.components.clicksend.* +homeassistant.components.climate.* homeassistant.components.cloud.* homeassistant.components.configurator.* homeassistant.components.cover.* @@ -136,9 +137,11 @@ homeassistant.components.fully_kiosk.* homeassistant.components.geo_location.* homeassistant.components.geocaching.* homeassistant.components.gios.* +homeassistant.components.glances.* homeassistant.components.goalzero.* homeassistant.components.google.* homeassistant.components.google_sheets.* +homeassistant.components.gpsd.* homeassistant.components.greeneye_monitor.* homeassistant.components.group.* homeassistant.components.guardian.* @@ -177,6 +180,7 @@ homeassistant.components.huawei_lte.* homeassistant.components.hydrawise.* homeassistant.components.hyperion.* homeassistant.components.ibeacon.* +homeassistant.components.idasen_desk.* homeassistant.components.image.* homeassistant.components.image_processing.* homeassistant.components.image_upload.* @@ -186,6 +190,7 @@ homeassistant.components.input_select.* homeassistant.components.integration.* homeassistant.components.ipp.* homeassistant.components.iqvia.* +homeassistant.components.islamic_prayer_times.* homeassistant.components.isy994.* homeassistant.components.jellyfin.* homeassistant.components.jewish_calendar.* @@ -213,6 +218,7 @@ homeassistant.components.lookin.* homeassistant.components.luftdaten.* homeassistant.components.mailbox.* homeassistant.components.mastodon.* +homeassistant.components.matrix.* homeassistant.components.matter.* homeassistant.components.media_extractor.* homeassistant.components.media_player.* @@ -253,7 +259,9 @@ homeassistant.components.peco.* homeassistant.components.persistent_notification.* homeassistant.components.pi_hole.* homeassistant.components.ping.* +homeassistant.components.plugwise.* homeassistant.components.powerwall.* +homeassistant.components.private_ble_device.* homeassistant.components.proximity.* homeassistant.components.prusalink.* homeassistant.components.pure_energie.* @@ -311,6 +319,7 @@ homeassistant.components.sun.* homeassistant.components.surepetcare.* homeassistant.components.switch.* homeassistant.components.switchbee.* +homeassistant.components.switchbot_cloud.* homeassistant.components.switcher_kis.* homeassistant.components.synology_dsm.* homeassistant.components.systemmonitor.* @@ -332,6 +341,7 @@ homeassistant.components.trafikverket_camera.* homeassistant.components.trafikverket_ferry.* homeassistant.components.trafikverket_train.* homeassistant.components.trafikverket_weatherstation.* +homeassistant.components.trend.* homeassistant.components.tts.* homeassistant.components.twentemilieu.* homeassistant.components.unifi.* diff --git a/CODEOWNERS b/CODEOWNERS index 65a36205518470..4fdf8845fe9606 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -47,8 +47,10 @@ build.json @home-assistant/supervisor /tests/components/airq/ @Sibgatulin @dl2080 /homeassistant/components/airthings/ @danielhiversen /tests/components/airthings/ @danielhiversen -/homeassistant/components/airthings_ble/ @vincegio -/tests/components/airthings_ble/ @vincegio +/homeassistant/components/airthings_ble/ @vincegio @LaStrada +/tests/components/airthings_ble/ @vincegio @LaStrada +/homeassistant/components/airtouch4/ @samsinnamon +/tests/components/airtouch4/ @samsinnamon /homeassistant/components/airvisual/ @bachya /tests/components/airvisual/ @bachya /homeassistant/components/airvisual_pro/ @bachya @@ -203,6 +205,8 @@ build.json @home-assistant/supervisor /tests/components/cloud/ @home-assistant/cloud /homeassistant/components/cloudflare/ @ludeeus @ctalkington /tests/components/cloudflare/ @ludeeus @ctalkington +/homeassistant/components/co2signal/ @jpbede +/tests/components/co2signal/ @jpbede /homeassistant/components/coinbase/ @tombrien /tests/components/coinbase/ @tombrien /homeassistant/components/color_extractor/ @GenericStudent @@ -305,6 +309,8 @@ build.json @home-assistant/supervisor /tests/components/easyenergy/ @klaasnicolaas /homeassistant/components/ecobee/ @marthoc @marcolivierarsenault /tests/components/ecobee/ @marthoc @marcolivierarsenault +/homeassistant/components/ecoforest/ @pjanuario +/tests/components/ecoforest/ @pjanuario /homeassistant/components/econet/ @vangorra @w1ll1am23 /tests/components/econet/ @vangorra @w1ll1am23 /homeassistant/components/ecovacs/ @OverloadUT @mib1185 @@ -396,8 +402,8 @@ build.json @home-assistant/supervisor /tests/components/flo/ @dmulcahey /homeassistant/components/flume/ @ChrisMandich @bdraco @jeeftor /tests/components/flume/ @ChrisMandich @bdraco @jeeftor -/homeassistant/components/flux_led/ @icemanch @bdraco -/tests/components/flux_led/ @icemanch @bdraco +/homeassistant/components/flux_led/ @icemanch +/tests/components/flux_led/ @icemanch /homeassistant/components/forecast_solar/ @klaasnicolaas @frenck /tests/components/forecast_solar/ @klaasnicolaas @frenck /homeassistant/components/forked_daapd/ @uvjustin @@ -565,6 +571,8 @@ build.json @home-assistant/supervisor /tests/components/ibeacon/ @bdraco /homeassistant/components/icloud/ @Quentame @nzapponi /tests/components/icloud/ @Quentame @nzapponi +/homeassistant/components/idasen_desk/ @abmantis +/tests/components/idasen_desk/ @abmantis /homeassistant/components/ign_sismologia/ @exxamalte /tests/components/ign_sismologia/ @exxamalte /homeassistant/components/image/ @home-assistant/core @@ -684,8 +692,6 @@ build.json @home-assistant/supervisor /tests/components/lidarr/ @tkdrob /homeassistant/components/life360/ @pnbruckner /tests/components/life360/ @pnbruckner -/homeassistant/components/lifx/ @bdraco -/tests/components/lifx/ @bdraco /homeassistant/components/light/ @home-assistant/core /tests/components/light/ @home-assistant/core /homeassistant/components/linux_battery/ @fabaff @@ -707,6 +713,8 @@ build.json @home-assistant/supervisor /tests/components/logger/ @home-assistant/core /homeassistant/components/logi_circle/ @evanjd /tests/components/logi_circle/ @evanjd +/homeassistant/components/london_underground/ @jpbede +/tests/components/london_underground/ @jpbede /homeassistant/components/lookin/ @ANMalko @bdraco /tests/components/lookin/ @ANMalko @bdraco /homeassistant/components/loqed/ @mikewoudenberg @@ -723,6 +731,8 @@ build.json @home-assistant/supervisor /homeassistant/components/lyric/ @timmo001 /tests/components/lyric/ @timmo001 /homeassistant/components/mastodon/ @fabaff +/homeassistant/components/matrix/ @PaarthShah +/tests/components/matrix/ @PaarthShah /homeassistant/components/matter/ @home-assistant/matter /tests/components/matter/ @home-assistant/matter /homeassistant/components/mazda/ @bdr99 @@ -766,8 +776,8 @@ build.json @home-assistant/supervisor /tests/components/moat/ @bdraco /homeassistant/components/mobile_app/ @home-assistant/core /tests/components/mobile_app/ @home-assistant/core -/homeassistant/components/modbus/ @adamchengtkc @janiversen @vzahradnik -/tests/components/modbus/ @adamchengtkc @janiversen @vzahradnik +/homeassistant/components/modbus/ @janiversen +/tests/components/modbus/ @janiversen /homeassistant/components/modem_callerid/ @tkdrob /tests/components/modem_callerid/ @tkdrob /homeassistant/components/modern_forms/ @wonderslug @@ -949,6 +959,8 @@ build.json @home-assistant/supervisor /tests/components/poolsense/ @haemishkyd /homeassistant/components/powerwall/ @bdraco @jrester @daniel-simpson /tests/components/powerwall/ @bdraco @jrester @daniel-simpson +/homeassistant/components/private_ble_device/ @Jc2k +/tests/components/private_ble_device/ @Jc2k /homeassistant/components/profiler/ @bdraco /tests/components/profiler/ @bdraco /homeassistant/components/progettihwsw/ @ardaseremet @@ -1057,8 +1069,8 @@ build.json @home-assistant/supervisor /tests/components/rss_feed_template/ @home-assistant/core /homeassistant/components/rtsp_to_webrtc/ @allenporter /tests/components/rtsp_to_webrtc/ @allenporter -/homeassistant/components/ruckus_unleashed/ @gabe565 @lanrat -/tests/components/ruckus_unleashed/ @gabe565 @lanrat +/homeassistant/components/ruckus_unleashed/ @lanrat @ms264556 @gabe565 +/tests/components/ruckus_unleashed/ @lanrat @ms264556 @gabe565 /homeassistant/components/ruuvi_gateway/ @akx /tests/components/ruuvi_gateway/ @akx /homeassistant/components/ruuvitag_ble/ @akx @@ -1135,8 +1147,8 @@ build.json @home-assistant/supervisor /homeassistant/components/sky_hub/ @rogerselwyn /homeassistant/components/skybell/ @tkdrob /tests/components/skybell/ @tkdrob -/homeassistant/components/slack/ @tkdrob -/tests/components/slack/ @tkdrob +/homeassistant/components/slack/ @tkdrob @fletcherau +/tests/components/slack/ @tkdrob @fletcherau /homeassistant/components/sleepiq/ @mfugate1 @kbickar /tests/components/sleepiq/ @mfugate1 @kbickar /homeassistant/components/slide/ @ualex73 @@ -1184,8 +1196,8 @@ build.json @home-assistant/supervisor /homeassistant/components/spider/ @peternijssen /tests/components/spider/ @peternijssen /homeassistant/components/splunk/ @Bre77 -/homeassistant/components/spotify/ @frenck -/tests/components/spotify/ @frenck +/homeassistant/components/spotify/ @frenck @joostlek +/tests/components/spotify/ @frenck @joostlek /homeassistant/components/sql/ @gjohansson-ST @dougiteixeira /tests/components/sql/ @gjohansson-ST @dougiteixeira /homeassistant/components/squeezebox/ @rajlaud @@ -1229,6 +1241,8 @@ build.json @home-assistant/supervisor /tests/components/switchbee/ @jafar-atili /homeassistant/components/switchbot/ @danielhiversen @RenierM26 @murtas @Eloston @dsypniewski /tests/components/switchbot/ @danielhiversen @RenierM26 @murtas @Eloston @dsypniewski +/homeassistant/components/switchbot_cloud/ @SeraphicRav +/tests/components/switchbot_cloud/ @SeraphicRav /homeassistant/components/switcher_kis/ @thecode /tests/components/switcher_kis/ @thecode /homeassistant/components/switchmate/ @danielhiversen @qiz-li @@ -1309,14 +1323,16 @@ build.json @home-assistant/supervisor /tests/components/trafikverket_weatherstation/ @endor-force @gjohansson-ST /homeassistant/components/transmission/ @engrbm87 @JPHutchins /tests/components/transmission/ @engrbm87 @JPHutchins +/homeassistant/components/trend/ @jpbede +/tests/components/trend/ @jpbede /homeassistant/components/tts/ @home-assistant/core @pvizeli /tests/components/tts/ @home-assistant/core @pvizeli /homeassistant/components/tuya/ @Tuya @zlinoliver @frenck /tests/components/tuya/ @Tuya @zlinoliver @frenck /homeassistant/components/twentemilieu/ @frenck /tests/components/twentemilieu/ @frenck -/homeassistant/components/twinkly/ @dr1rrb @Robbie1221 -/tests/components/twinkly/ @dr1rrb @Robbie1221 +/homeassistant/components/twinkly/ @dr1rrb @Robbie1221 @Olen +/tests/components/twinkly/ @dr1rrb @Robbie1221 @Olen /homeassistant/components/twitch/ @joostlek /tests/components/twitch/ @joostlek /homeassistant/components/ukraine_alarm/ @PaulAnnekov @@ -1352,11 +1368,11 @@ build.json @home-assistant/supervisor /homeassistant/components/velbus/ @Cereal2nd @brefra /tests/components/velbus/ @Cereal2nd @brefra /homeassistant/components/velux/ @Julius2342 -/homeassistant/components/venstar/ @garbled1 -/tests/components/venstar/ @garbled1 -/homeassistant/components/verisure/ @frenck @niro1987 -/tests/components/verisure/ @frenck @niro1987 -/homeassistant/components/versasense/ @flamm3blemuff1n +/homeassistant/components/venstar/ @garbled1 @jhollowe +/tests/components/venstar/ @garbled1 @jhollowe +/homeassistant/components/verisure/ @frenck +/tests/components/verisure/ @frenck +/homeassistant/components/versasense/ @imstevenxyz /homeassistant/components/version/ @ludeeus /tests/components/version/ @ludeeus /homeassistant/components/vesync/ @markperdue @webdjoe @thegardenmonkey @@ -1384,7 +1400,8 @@ build.json @home-assistant/supervisor /tests/components/wake_word/ @home-assistant/core @synesthesiam /homeassistant/components/wallbox/ @hesselonline /tests/components/wallbox/ @hesselonline -/homeassistant/components/waqi/ @andrey-git +/homeassistant/components/waqi/ @joostlek +/tests/components/waqi/ @joostlek /homeassistant/components/water_heater/ @home-assistant/core /tests/components/water_heater/ @home-assistant/core /homeassistant/components/watson_tts/ @rutkai @@ -1394,6 +1411,8 @@ build.json @home-assistant/supervisor /tests/components/waze_travel_time/ @eifinger /homeassistant/components/weather/ @home-assistant/core /tests/components/weather/ @home-assistant/core +/homeassistant/components/weatherkit/ @tjhorner +/tests/components/weatherkit/ @tjhorner /homeassistant/components/webhook/ @home-assistant/core /tests/components/webhook/ @home-assistant/core /homeassistant/components/webostv/ @thecode @@ -1411,8 +1430,8 @@ build.json @home-assistant/supervisor /homeassistant/components/wilight/ @leofig-rj /tests/components/wilight/ @leofig-rj /homeassistant/components/wirelesstag/ @sergeymaysak -/homeassistant/components/withings/ @vangorra -/tests/components/withings/ @vangorra +/homeassistant/components/withings/ @vangorra @joostlek +/tests/components/withings/ @vangorra @joostlek /homeassistant/components/wiz/ @sbidy /tests/components/wiz/ @sbidy /homeassistant/components/wled/ @frenck @@ -1446,6 +1465,7 @@ build.json @home-assistant/supervisor /homeassistant/components/yandex_transport/ @rishatik92 @devbis /tests/components/yandex_transport/ @rishatik92 @devbis /homeassistant/components/yardian/ @h3l1o5 +/tests/components/yardian/ @h3l1o5 /homeassistant/components/yeelight/ @zewelor @shenxn @starkillerOG @alexyao2015 /tests/components/yeelight/ @zewelor @shenxn @starkillerOG @alexyao2015 /homeassistant/components/yeelightsunflower/ @lindsaymarkward diff --git a/homeassistant/brands/apple.json b/homeassistant/brands/apple.json index 00f646e435ec7c..b0b66de0bccafa 100644 --- a/homeassistant/brands/apple.json +++ b/homeassistant/brands/apple.json @@ -7,6 +7,7 @@ "homekit", "ibeacon", "icloud", - "itunes" + "itunes", + "weatherkit" ] } diff --git a/homeassistant/brands/ikea.json b/homeassistant/brands/ikea.json index 702a59ad4d1b08..dee69001adda5d 100644 --- a/homeassistant/brands/ikea.json +++ b/homeassistant/brands/ikea.json @@ -1,5 +1,5 @@ { "domain": "ikea", "name": "IKEA", - "integrations": ["symfonisk", "tradfri"] + "integrations": ["symfonisk", "tradfri", "idasen_desk"] } diff --git a/homeassistant/brands/switchbot.json b/homeassistant/brands/switchbot.json new file mode 100644 index 00000000000000..0909b24a146990 --- /dev/null +++ b/homeassistant/brands/switchbot.json @@ -0,0 +1,5 @@ +{ + "domain": "switchbot", + "name": "SwitchBot", + "integrations": ["switchbot", "switchbot_cloud"] +} diff --git a/homeassistant/components/aemet/const.py b/homeassistant/components/aemet/const.py index c6c4a9c1628ed0..7940ff92f726ca 100644 --- a/homeassistant/components/aemet/const.py +++ b/homeassistant/components/aemet/const.py @@ -1,6 +1,19 @@ """Constant values for the AEMET OpenData component.""" from __future__ import annotations +from aemet_opendata.const import ( + AOD_COND_CLEAR_NIGHT, + AOD_COND_CLOUDY, + AOD_COND_FOG, + AOD_COND_LIGHTNING, + AOD_COND_LIGHTNING_RAINY, + AOD_COND_PARTLY_CLODUY, + AOD_COND_POURING, + AOD_COND_RAINY, + AOD_COND_SNOWY, + AOD_COND_SUNNY, +) + from homeassistant.components.weather import ( ATTR_CONDITION_CLEAR_NIGHT, ATTR_CONDITION_CLOUDY, @@ -55,94 +68,16 @@ ATTR_API_WIND_SPEED = "wind-speed" CONDITIONS_MAP = { - ATTR_CONDITION_CLEAR_NIGHT: { - "11n", # Despejado (de noche) - }, - ATTR_CONDITION_CLOUDY: { - "14", # Nuboso - "14n", # Nuboso (de noche) - "15", # Muy nuboso - "15n", # Muy nuboso (de noche) - "16", # Cubierto - "16n", # Cubierto (de noche) - "17", # Nubes altas - "17n", # Nubes altas (de noche) - }, - ATTR_CONDITION_FOG: { - "81", # Niebla - "81n", # Niebla (de noche) - "82", # Bruma - Neblina - "82n", # Bruma - Neblina (de noche) - }, - ATTR_CONDITION_LIGHTNING: { - "51", # Intervalos nubosos con tormenta - "51n", # Intervalos nubosos con tormenta (de noche) - "52", # Nuboso con tormenta - "52n", # Nuboso con tormenta (de noche) - "53", # Muy nuboso con tormenta - "53n", # Muy nuboso con tormenta (de noche) - "54", # Cubierto con tormenta - "54n", # Cubierto con tormenta (de noche) - }, - ATTR_CONDITION_LIGHTNING_RAINY: { - "61", # Intervalos nubosos con tormenta y lluvia escasa - "61n", # Intervalos nubosos con tormenta y lluvia escasa (de noche) - "62", # Nuboso con tormenta y lluvia escasa - "62n", # Nuboso con tormenta y lluvia escasa (de noche) - "63", # Muy nuboso con tormenta y lluvia escasa - "63n", # Muy nuboso con tormenta y lluvia escasa (de noche) - "64", # Cubierto con tormenta y lluvia escasa - "64n", # Cubierto con tormenta y lluvia escasa (de noche) - }, - ATTR_CONDITION_PARTLYCLOUDY: { - "12", # Poco nuboso - "12n", # Poco nuboso (de noche) - "13", # Intervalos nubosos - "13n", # Intervalos nubosos (de noche) - }, - ATTR_CONDITION_POURING: { - "27", # Chubascos - "27n", # Chubascos (de noche) - }, - ATTR_CONDITION_RAINY: { - "23", # Intervalos nubosos con lluvia - "23n", # Intervalos nubosos con lluvia (de noche) - "24", # Nuboso con lluvia - "24n", # Nuboso con lluvia (de noche) - "25", # Muy nuboso con lluvia - "25n", # Muy nuboso con lluvia (de noche) - "26", # Cubierto con lluvia - "26n", # Cubierto con lluvia (de noche) - "43", # Intervalos nubosos con lluvia escasa - "43n", # Intervalos nubosos con lluvia escasa (de noche) - "44", # Nuboso con lluvia escasa - "44n", # Nuboso con lluvia escasa (de noche) - "45", # Muy nuboso con lluvia escasa - "45n", # Muy nuboso con lluvia escasa (de noche) - "46", # Cubierto con lluvia escasa - "46n", # Cubierto con lluvia escasa (de noche) - }, - ATTR_CONDITION_SNOWY: { - "33", # Intervalos nubosos con nieve - "33n", # Intervalos nubosos con nieve (de noche) - "34", # Nuboso con nieve - "34n", # Nuboso con nieve (de noche) - "35", # Muy nuboso con nieve - "35n", # Muy nuboso con nieve (de noche) - "36", # Cubierto con nieve - "36n", # Cubierto con nieve (de noche) - "71", # Intervalos nubosos con nieve escasa - "71n", # Intervalos nubosos con nieve escasa (de noche) - "72", # Nuboso con nieve escasa - "72n", # Nuboso con nieve escasa (de noche) - "73", # Muy nuboso con nieve escasa - "73n", # Muy nuboso con nieve escasa (de noche) - "74", # Cubierto con nieve escasa - "74n", # Cubierto con nieve escasa (de noche) - }, - ATTR_CONDITION_SUNNY: { - "11", # Despejado - }, + AOD_COND_CLEAR_NIGHT: ATTR_CONDITION_CLEAR_NIGHT, + AOD_COND_CLOUDY: ATTR_CONDITION_CLOUDY, + AOD_COND_FOG: ATTR_CONDITION_FOG, + AOD_COND_LIGHTNING: ATTR_CONDITION_LIGHTNING, + AOD_COND_LIGHTNING_RAINY: ATTR_CONDITION_LIGHTNING_RAINY, + AOD_COND_PARTLY_CLODUY: ATTR_CONDITION_PARTLYCLOUDY, + AOD_COND_POURING: ATTR_CONDITION_POURING, + AOD_COND_RAINY: ATTR_CONDITION_RAINY, + AOD_COND_SNOWY: ATTR_CONDITION_SNOWY, + AOD_COND_SUNNY: ATTR_CONDITION_SUNNY, } FORECAST_MONITORED_CONDITIONS = [ @@ -187,16 +122,3 @@ FORECAST_MODE_DAILY: ATTR_API_FORECAST_DAILY, FORECAST_MODE_HOURLY: ATTR_API_FORECAST_HOURLY, } - - -WIND_BEARING_MAP = { - "C": None, - "N": 0.0, - "NE": 45.0, - "E": 90.0, - "SE": 135.0, - "S": 180.0, - "SO": 225.0, - "O": 270.0, - "NO": 315.0, -} diff --git a/homeassistant/components/aemet/sensor.py b/homeassistant/components/aemet/sensor.py index f7aa6b358933f8..76e691a4682221 100644 --- a/homeassistant/components/aemet/sensor.py +++ b/homeassistant/components/aemet/sensor.py @@ -30,6 +30,7 @@ ATTR_API_FORECAST_TEMP_LOW, ATTR_API_FORECAST_TIME, ATTR_API_FORECAST_WIND_BEARING, + ATTR_API_FORECAST_WIND_MAX_SPEED, ATTR_API_FORECAST_WIND_SPEED, ATTR_API_HUMIDITY, ATTR_API_PRESSURE, @@ -99,6 +100,12 @@ name="Wind bearing", native_unit_of_measurement=DEGREE, ), + SensorEntityDescription( + key=ATTR_API_FORECAST_WIND_MAX_SPEED, + name="Wind max speed", + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + device_class=SensorDeviceClass.WIND_SPEED, + ), SensorEntityDescription( key=ATTR_API_FORECAST_WIND_SPEED, name="Wind speed", @@ -206,13 +213,14 @@ name="Wind max speed", native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, device_class=SensorDeviceClass.WIND_SPEED, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=ATTR_API_WIND_SPEED, name="Wind speed", native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, - state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.WIND_SPEED, + state_class=SensorStateClass.MEASUREMENT, ), ) diff --git a/homeassistant/components/aemet/weather.py b/homeassistant/components/aemet/weather.py index e3a1922c2f1d48..03f91a74740f8a 100644 --- a/homeassistant/components/aemet/weather.py +++ b/homeassistant/components/aemet/weather.py @@ -42,6 +42,7 @@ ATTR_API_PRESSURE, ATTR_API_TEMPERATURE, ATTR_API_WIND_BEARING, + ATTR_API_WIND_MAX_SPEED, ATTR_API_WIND_SPEED, ATTRIBUTION, DOMAIN, @@ -193,6 +194,11 @@ def wind_bearing(self): """Return the wind bearing.""" return self.coordinator.data[ATTR_API_WIND_BEARING] + @property + def native_wind_gust_speed(self): + """Return the wind gust speed in native units.""" + return self.coordinator.data[ATTR_API_WIND_MAX_SPEED] + @property def native_wind_speed(self): """Return the wind speed.""" diff --git a/homeassistant/components/aemet/weather_update_coordinator.py b/homeassistant/components/aemet/weather_update_coordinator.py index c6e27374f8f181..01c2502fb37aa0 100644 --- a/homeassistant/components/aemet/weather_update_coordinator.py +++ b/homeassistant/components/aemet/weather_update_coordinator.py @@ -34,6 +34,7 @@ ATTR_DATA, ) from aemet_opendata.exceptions import AemetError +from aemet_opendata.forecast import ForecastValue from aemet_opendata.helpers import ( get_forecast_day_value, get_forecast_hour_value, @@ -78,7 +79,6 @@ ATTR_API_WIND_SPEED, CONDITIONS_MAP, DOMAIN, - WIND_BEARING_MAP, ) _LOGGER = logging.getLogger(__name__) @@ -90,11 +90,8 @@ def format_condition(condition: str) -> str: """Return condition from dict CONDITIONS_MAP.""" - for key, value in CONDITIONS_MAP.items(): - if condition in value: - return key - _LOGGER.error('Condition "%s" not found in CONDITIONS_MAP', condition) - return condition + val = ForecastValue.parse_condition(condition) + return CONDITIONS_MAP.get(val, val) def format_float(value) -> float | None: @@ -489,10 +486,7 @@ def _get_wind_bearing(day_data, hour): val = get_forecast_hour_value( day_data[AEMET_ATTR_WIND_GUST], hour, key=AEMET_ATTR_DIRECTION )[0] - if val in WIND_BEARING_MAP: - return WIND_BEARING_MAP[val] - _LOGGER.error("%s not found in Wind Bearing map", val) - return None + return ForecastValue.parse_wind_direction(val) @staticmethod def _get_wind_bearing_day(day_data): @@ -500,10 +494,7 @@ def _get_wind_bearing_day(day_data): val = get_forecast_day_value( day_data[AEMET_ATTR_WIND], key=AEMET_ATTR_DIRECTION ) - if val in WIND_BEARING_MAP: - return WIND_BEARING_MAP[val] - _LOGGER.error("%s not found in Wind Bearing map", val) - return None + return ForecastValue.parse_wind_direction(val) @staticmethod def _get_wind_max_speed(day_data, hour): diff --git a/homeassistant/components/airly/__init__.py b/homeassistant/components/airly/__init__.py index 982687c7723ec6..91208de519b1da 100644 --- a/homeassistant/components/airly/__init__.py +++ b/homeassistant/components/airly/__init__.py @@ -1,15 +1,8 @@ """The Airly integration.""" from __future__ import annotations -from asyncio import timeout from datetime import timedelta import logging -from math import ceil - -from aiohttp import ClientSession -from aiohttp.client_exceptions import ClientConnectorError -from airly import Airly -from airly.exceptions import AirlyError from homeassistant.components.air_quality import DOMAIN as AIR_QUALITY_PLATFORM from homeassistant.config_entries import ConfigEntry @@ -17,53 +10,15 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from homeassistant.util import dt as dt_util - -from .const import ( - ATTR_API_ADVICE, - ATTR_API_CAQI, - ATTR_API_CAQI_DESCRIPTION, - ATTR_API_CAQI_LEVEL, - CONF_USE_NEAREST, - DOMAIN, - MAX_UPDATE_INTERVAL, - MIN_UPDATE_INTERVAL, - NO_AIRLY_SENSORS, -) + +from .const import CONF_USE_NEAREST, DOMAIN, MIN_UPDATE_INTERVAL +from .coordinator import AirlyDataUpdateCoordinator PLATFORMS = [Platform.SENSOR] _LOGGER = logging.getLogger(__name__) -def set_update_interval(instances_count: int, requests_remaining: int) -> timedelta: - """Return data update interval. - - The number of requests is reset at midnight UTC so we calculate the update - interval based on number of minutes until midnight, the number of Airly instances - and the number of remaining requests. - """ - now = dt_util.utcnow() - midnight = dt_util.find_next_time_expression_time( - now, seconds=[0], minutes=[0], hours=[0] - ) - minutes_to_midnight = (midnight - now).total_seconds() / 60 - interval = timedelta( - minutes=min( - max( - ceil(minutes_to_midnight / requests_remaining * instances_count), - MIN_UPDATE_INTERVAL, - ), - MAX_UPDATE_INTERVAL, - ) - ) - - _LOGGER.debug("Data will be update every %s", interval) - - return interval - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Airly as config entry.""" api_key = entry.data[CONF_API_KEY] @@ -131,75 +86,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -class AirlyDataUpdateCoordinator(DataUpdateCoordinator): - """Define an object to hold Airly data.""" - - def __init__( - self, - hass: HomeAssistant, - session: ClientSession, - api_key: str, - latitude: float, - longitude: float, - update_interval: timedelta, - use_nearest: bool, - ) -> None: - """Initialize.""" - self.latitude = latitude - self.longitude = longitude - # Currently, Airly only supports Polish and English - language = "pl" if hass.config.language == "pl" else "en" - self.airly = Airly(api_key, session, language=language) - self.use_nearest = use_nearest - - super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=update_interval) - - async def _async_update_data(self) -> dict[str, str | float | int]: - """Update data via library.""" - data: dict[str, str | float | int] = {} - if self.use_nearest: - measurements = self.airly.create_measurements_session_nearest( - self.latitude, self.longitude, max_distance_km=5 - ) - else: - measurements = self.airly.create_measurements_session_point( - self.latitude, self.longitude - ) - async with timeout(20): - try: - await measurements.update() - except (AirlyError, ClientConnectorError) as error: - raise UpdateFailed(error) from error - - _LOGGER.debug( - "Requests remaining: %s/%s", - self.airly.requests_remaining, - self.airly.requests_per_day, - ) - - # Airly API sometimes returns None for requests remaining so we update - # update_interval only if we have valid value. - if self.airly.requests_remaining: - self.update_interval = set_update_interval( - len(self.hass.config_entries.async_entries(DOMAIN)), - self.airly.requests_remaining, - ) - - values = measurements.current["values"] - index = measurements.current["indexes"][0] - standards = measurements.current["standards"] - - if index["description"] == NO_AIRLY_SENSORS: - raise UpdateFailed("Can't retrieve data: no Airly sensors in this area") - for value in values: - data[value["name"]] = value["value"] - for standard in standards: - data[f"{standard['pollutant']}_LIMIT"] = standard["limit"] - data[f"{standard['pollutant']}_PERCENT"] = standard["percent"] - data[ATTR_API_CAQI] = index["value"] - data[ATTR_API_CAQI_LEVEL] = index["level"].lower().replace("_", " ") - data[ATTR_API_CAQI_DESCRIPTION] = index["description"] - data[ATTR_API_ADVICE] = index["advice"] - return data diff --git a/homeassistant/components/airly/coordinator.py b/homeassistant/components/airly/coordinator.py new file mode 100644 index 00000000000000..9f2a1c965114f8 --- /dev/null +++ b/homeassistant/components/airly/coordinator.py @@ -0,0 +1,126 @@ +"""DataUpdateCoordinator for the Airly integration.""" +from asyncio import timeout +from datetime import timedelta +import logging +from math import ceil + +from aiohttp import ClientSession +from aiohttp.client_exceptions import ClientConnectorError +from airly import Airly +from airly.exceptions import AirlyError + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util import dt as dt_util + +from .const import ( + ATTR_API_ADVICE, + ATTR_API_CAQI, + ATTR_API_CAQI_DESCRIPTION, + ATTR_API_CAQI_LEVEL, + DOMAIN, + MAX_UPDATE_INTERVAL, + MIN_UPDATE_INTERVAL, + NO_AIRLY_SENSORS, +) + +_LOGGER = logging.getLogger(__name__) + + +def set_update_interval(instances_count: int, requests_remaining: int) -> timedelta: + """Return data update interval. + + The number of requests is reset at midnight UTC so we calculate the update + interval based on number of minutes until midnight, the number of Airly instances + and the number of remaining requests. + """ + now = dt_util.utcnow() + midnight = dt_util.find_next_time_expression_time( + now, seconds=[0], minutes=[0], hours=[0] + ) + minutes_to_midnight = (midnight - now).total_seconds() / 60 + interval = timedelta( + minutes=min( + max( + ceil(minutes_to_midnight / requests_remaining * instances_count), + MIN_UPDATE_INTERVAL, + ), + MAX_UPDATE_INTERVAL, + ) + ) + + _LOGGER.debug("Data will be update every %s", interval) + + return interval + + +class AirlyDataUpdateCoordinator(DataUpdateCoordinator): + """Define an object to hold Airly data.""" + + def __init__( + self, + hass: HomeAssistant, + session: ClientSession, + api_key: str, + latitude: float, + longitude: float, + update_interval: timedelta, + use_nearest: bool, + ) -> None: + """Initialize.""" + self.latitude = latitude + self.longitude = longitude + # Currently, Airly only supports Polish and English + language = "pl" if hass.config.language == "pl" else "en" + self.airly = Airly(api_key, session, language=language) + self.use_nearest = use_nearest + + super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=update_interval) + + async def _async_update_data(self) -> dict[str, str | float | int]: + """Update data via library.""" + data: dict[str, str | float | int] = {} + if self.use_nearest: + measurements = self.airly.create_measurements_session_nearest( + self.latitude, self.longitude, max_distance_km=5 + ) + else: + measurements = self.airly.create_measurements_session_point( + self.latitude, self.longitude + ) + async with timeout(20): + try: + await measurements.update() + except (AirlyError, ClientConnectorError) as error: + raise UpdateFailed(error) from error + + _LOGGER.debug( + "Requests remaining: %s/%s", + self.airly.requests_remaining, + self.airly.requests_per_day, + ) + + # Airly API sometimes returns None for requests remaining so we update + # update_interval only if we have valid value. + if self.airly.requests_remaining: + self.update_interval = set_update_interval( + len(self.hass.config_entries.async_entries(DOMAIN)), + self.airly.requests_remaining, + ) + + values = measurements.current["values"] + index = measurements.current["indexes"][0] + standards = measurements.current["standards"] + + if index["description"] == NO_AIRLY_SENSORS: + raise UpdateFailed("Can't retrieve data: no Airly sensors in this area") + for value in values: + data[value["name"]] = value["value"] + for standard in standards: + data[f"{standard['pollutant']}_LIMIT"] = standard["limit"] + data[f"{standard['pollutant']}_PERCENT"] = standard["percent"] + data[ATTR_API_CAQI] = index["value"] + data[ATTR_API_CAQI_LEVEL] = index["level"].lower().replace("_", " ") + data[ATTR_API_CAQI_DESCRIPTION] = index["description"] + data[ATTR_API_ADVICE] = index["advice"] + return data diff --git a/homeassistant/components/airnow/sensor.py b/homeassistant/components/airnow/sensor.py index 09393741d636e6..c83232c273a78c 100644 --- a/homeassistant/components/airnow/sensor.py +++ b/homeassistant/components/airnow/sensor.py @@ -58,6 +58,16 @@ class AirNowEntityDescription(SensorEntityDescription, AirNowEntityDescriptionMi """Describes Airnow sensor entity.""" +def station_extra_attrs(data: dict[str, Any]) -> dict[str, Any]: + """Process extra attributes for station location (if available).""" + if ATTR_API_STATION in data: + return { + "lat": data.get(ATTR_API_STATION_LATITUDE), + "long": data.get(ATTR_API_STATION_LONGITUDE), + } + return {} + + SENSOR_TYPES: tuple[AirNowEntityDescription, ...] = ( AirNowEntityDescription( key=ATTR_API_AQI, @@ -93,10 +103,7 @@ class AirNowEntityDescription(SensorEntityDescription, AirNowEntityDescriptionMi translation_key="station", icon="mdi:blur", value_fn=lambda data: data.get(ATTR_API_STATION), - extra_state_attributes_fn=lambda data: { - "lat": data[ATTR_API_STATION_LATITUDE], - "long": data[ATTR_API_STATION_LONGITUDE], - }, + extra_state_attributes_fn=station_extra_attrs, ), ) diff --git a/homeassistant/components/airthings_ble/manifest.json b/homeassistant/components/airthings_ble/manifest.json index ef9ad3a802e97d..cb7114ff8ff715 100644 --- a/homeassistant/components/airthings_ble/manifest.json +++ b/homeassistant/components/airthings_ble/manifest.json @@ -19,7 +19,7 @@ "service_uuid": "b42e3882-ade7-11e4-89d3-123b93f75cba" } ], - "codeowners": ["@vincegio"], + "codeowners": ["@vincegio", "@LaStrada"], "config_flow": true, "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/airthings_ble", diff --git a/homeassistant/components/airthings_ble/sensor.py b/homeassistant/components/airthings_ble/sensor.py index 4783f3e3b35a7e..28b5fa3a7a6752 100644 --- a/homeassistant/components/airthings_ble/sensor.py +++ b/homeassistant/components/airthings_ble/sensor.py @@ -5,25 +5,35 @@ from airthings_ble import AirthingsDevice -from homeassistant import config_entries from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, SensorStateClass, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONCENTRATION_PARTS_PER_BILLION, CONCENTRATION_PARTS_PER_MILLION, LIGHT_LUX, PERCENTAGE, EntityCategory, + Platform, UnitOfPressure, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import ( + CONNECTION_BLUETOOTH, + DeviceInfo, + async_get as device_async_get, +) from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_registry import ( + RegistryEntry, + async_entries_for_device, + async_get as entity_async_get, +) from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, @@ -107,9 +117,44 @@ } +@callback +def async_migrate(hass: HomeAssistant, address: str, sensor_name: str) -> None: + """Migrate entities to new unique ids (with BLE Address).""" + ent_reg = entity_async_get(hass) + unique_id_trailer = f"_{sensor_name}" + new_unique_id = f"{address}{unique_id_trailer}" + if ent_reg.async_get_entity_id(DOMAIN, Platform.SENSOR, new_unique_id): + # New unique id already exists + return + dev_reg = device_async_get(hass) + if not ( + device := dev_reg.async_get_device( + connections={(CONNECTION_BLUETOOTH, address)} + ) + ): + return + entities = async_entries_for_device( + ent_reg, + device_id=device.id, + include_disabled_entities=True, + ) + matching_reg_entry: RegistryEntry | None = None + for entry in entities: + if entry.unique_id.endswith(unique_id_trailer) and ( + not matching_reg_entry or "(" not in entry.unique_id + ): + matching_reg_entry = entry + if not matching_reg_entry or matching_reg_entry.unique_id == new_unique_id: + # Already has the newest unique id format + return + entity_id = matching_reg_entry.entity_id + ent_reg.async_update_entity(entity_id=entity_id, new_unique_id=new_unique_id) + _LOGGER.debug("Migrated entity '%s' to unique id '%s'", entity_id, new_unique_id) + + async def async_setup_entry( hass: HomeAssistant, - entry: config_entries.ConfigEntry, + entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Airthings BLE sensors.""" @@ -137,6 +182,7 @@ async def async_setup_entry( sensor_value, ) continue + async_migrate(hass, coordinator.data.address, sensor_type) entities.append( AirthingsSensor(coordinator, coordinator.data, sensors_mapping[sensor_type]) ) @@ -165,7 +211,7 @@ def __init__( if identifier := airthings_device.identifier: name += f" ({identifier})" - self._attr_unique_id = f"{name}_{entity_description.key}" + self._attr_unique_id = f"{airthings_device.address}_{entity_description.key}" self._attr_device_info = DeviceInfo( connections={ ( diff --git a/homeassistant/components/airtouch4/__init__.py b/homeassistant/components/airtouch4/__init__.py index a2c3f716ab1bb9..dc5172096a728d 100644 --- a/homeassistant/components/airtouch4/__init__.py +++ b/homeassistant/components/airtouch4/__init__.py @@ -1,19 +1,13 @@ """The AirTouch4 integration.""" -import logging - from airtouch4pyapi import AirTouch -from airtouch4pyapi.airtouch import AirTouchStatus -from homeassistant.components.climate import SCAN_INTERVAL from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN - -_LOGGER = logging.getLogger(__name__) +from .coordinator import AirtouchDataUpdateCoordinator PLATFORMS = [Platform.CLIMATE] @@ -44,38 +38,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -class AirtouchDataUpdateCoordinator(DataUpdateCoordinator): - """Class to manage fetching Airtouch data.""" - - def __init__(self, hass, airtouch): - """Initialize global Airtouch data updater.""" - self.airtouch = airtouch - - super().__init__( - hass, - _LOGGER, - name=DOMAIN, - update_interval=SCAN_INTERVAL, - ) - - async def _async_update_data(self): - """Fetch data from Airtouch.""" - await self.airtouch.UpdateInfo() - if self.airtouch.Status != AirTouchStatus.OK: - raise UpdateFailed("Airtouch connection issue") - return { - "acs": [ - {"ac_number": ac.AcNumber, "is_on": ac.IsOn} - for ac in self.airtouch.GetAcs() - ], - "groups": [ - { - "group_number": group.GroupNumber, - "group_name": group.GroupName, - "is_on": group.IsOn, - } - for group in self.airtouch.GetGroups() - ], - } diff --git a/homeassistant/components/airtouch4/coordinator.py b/homeassistant/components/airtouch4/coordinator.py new file mode 100644 index 00000000000000..e78bf62dbd0ec2 --- /dev/null +++ b/homeassistant/components/airtouch4/coordinator.py @@ -0,0 +1,46 @@ +"""DataUpdateCoordinator for the airtouch integration.""" +import logging + +from airtouch4pyapi.airtouch import AirTouchStatus + +from homeassistant.components.climate import SCAN_INTERVAL +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class AirtouchDataUpdateCoordinator(DataUpdateCoordinator): + """Class to manage fetching Airtouch data.""" + + def __init__(self, hass, airtouch): + """Initialize global Airtouch data updater.""" + self.airtouch = airtouch + + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + + async def _async_update_data(self): + """Fetch data from Airtouch.""" + await self.airtouch.UpdateInfo() + if self.airtouch.Status != AirTouchStatus.OK: + raise UpdateFailed("Airtouch connection issue") + return { + "acs": [ + {"ac_number": ac.AcNumber, "is_on": ac.IsOn} + for ac in self.airtouch.GetAcs() + ], + "groups": [ + { + "group_number": group.GroupNumber, + "group_name": group.GroupName, + "is_on": group.IsOn, + } + for group in self.airtouch.GetGroups() + ], + } diff --git a/homeassistant/components/airtouch4/manifest.json b/homeassistant/components/airtouch4/manifest.json index e845c278a543bd..8a1f947af64988 100644 --- a/homeassistant/components/airtouch4/manifest.json +++ b/homeassistant/components/airtouch4/manifest.json @@ -1,7 +1,7 @@ { "domain": "airtouch4", "name": "AirTouch 4", - "codeowners": [], + "codeowners": ["@samsinnamon"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/airtouch4", "iot_class": "local_polling", diff --git a/homeassistant/components/airvisual/__init__.py b/homeassistant/components/airvisual/__init__.py index 21be2e5d664a6b..8860db69b7916a 100644 --- a/homeassistant/components/airvisual/__init__.py +++ b/homeassistant/components/airvisual/__init__.py @@ -421,6 +421,7 @@ def __init__( self._entry = entry self.entity_description = description + # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: """Register callbacks.""" diff --git a/homeassistant/components/airzone/__init__.py b/homeassistant/components/airzone/__init__.py index de75bf03d454f7..1a54be0ac41f32 100644 --- a/homeassistant/components/airzone/__init__.py +++ b/homeassistant/components/airzone/__init__.py @@ -24,6 +24,7 @@ Platform.CLIMATE, Platform.SELECT, Platform.SENSOR, + Platform.WATER_HEATER, ] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/airzone/entity.py b/homeassistant/components/airzone/entity.py index 267cd210ff0e23..2310d5fb5a4cbf 100644 --- a/homeassistant/components/airzone/entity.py +++ b/homeassistant/components/airzone/entity.py @@ -106,6 +106,22 @@ def get_airzone_value(self, key: str) -> Any: """Return DHW value by key.""" return self.coordinator.data[AZD_HOT_WATER].get(key) + async def _async_update_dhw_params(self, params: dict[str, Any]) -> None: + """Send DHW parameters to API.""" + _params = { + API_SYSTEM_ID: 0, + **params, + } + _LOGGER.debug("update_dhw_params=%s", _params) + try: + await self.coordinator.airzone.set_dhw_parameters(_params) + except AirzoneError as error: + raise HomeAssistantError( + f"Failed to set dhw {self.name}: {error}" + ) from error + + self.coordinator.async_set_updated_data(self.coordinator.airzone.data()) + class AirzoneWebServerEntity(AirzoneEntity): """Define an Airzone WebServer entity.""" diff --git a/homeassistant/components/airzone/manifest.json b/homeassistant/components/airzone/manifest.json index bb1e448c8ebbf3..c0b24b2cc3e8e4 100644 --- a/homeassistant/components/airzone/manifest.json +++ b/homeassistant/components/airzone/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone", "iot_class": "local_polling", "loggers": ["aioairzone"], - "requirements": ["aioairzone==0.6.7"] + "requirements": ["aioairzone==0.6.8"] } diff --git a/homeassistant/components/airzone/water_heater.py b/homeassistant/components/airzone/water_heater.py new file mode 100644 index 00000000000000..b19aa36449c9b9 --- /dev/null +++ b/homeassistant/components/airzone/water_heater.py @@ -0,0 +1,131 @@ +"""Support for the Airzone water heater.""" +from __future__ import annotations + +from typing import Any, Final + +from aioairzone.common import HotWaterOperation +from aioairzone.const import ( + API_ACS_ON, + API_ACS_POWER_MODE, + API_ACS_SET_POINT, + AZD_HOT_WATER, + AZD_NAME, + AZD_OPERATION, + AZD_OPERATIONS, + AZD_TEMP, + AZD_TEMP_MAX, + AZD_TEMP_MIN, + AZD_TEMP_SET, + AZD_TEMP_UNIT, +) + +from homeassistant.components.water_heater import ( + STATE_ECO, + STATE_PERFORMANCE, + WaterHeaterEntity, + WaterHeaterEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_TEMPERATURE, STATE_OFF +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN, TEMP_UNIT_LIB_TO_HASS +from .coordinator import AirzoneUpdateCoordinator +from .entity import AirzoneHotWaterEntity + +OPERATION_LIB_TO_HASS: Final[dict[HotWaterOperation, str]] = { + HotWaterOperation.Off: STATE_OFF, + HotWaterOperation.On: STATE_ECO, + HotWaterOperation.Powerful: STATE_PERFORMANCE, +} + +OPERATION_MODE_TO_DHW_PARAMS: Final[dict[str, dict[str, Any]]] = { + STATE_OFF: { + API_ACS_ON: 0, + }, + STATE_ECO: { + API_ACS_ON: 1, + API_ACS_POWER_MODE: 0, + }, + STATE_PERFORMANCE: { + API_ACS_ON: 1, + API_ACS_POWER_MODE: 1, + }, +} + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Add Airzone sensors from a config_entry.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + if AZD_HOT_WATER in coordinator.data: + async_add_entities([AirzoneWaterHeater(coordinator, entry)]) + + +class AirzoneWaterHeater(AirzoneHotWaterEntity, WaterHeaterEntity): + """Define an Airzone Water Heater.""" + + _attr_supported_features = ( + WaterHeaterEntityFeature.TARGET_TEMPERATURE + | WaterHeaterEntityFeature.ON_OFF + | WaterHeaterEntityFeature.OPERATION_MODE + ) + + def __init__( + self, + coordinator: AirzoneUpdateCoordinator, + entry: ConfigEntry, + ) -> None: + """Initialize Airzone water heater entity.""" + super().__init__(coordinator, entry) + + self._attr_name = self.get_airzone_value(AZD_NAME) + self._attr_unique_id = f"{self._attr_unique_id}_dhw" + self._attr_operation_list = [ + OPERATION_LIB_TO_HASS[operation] + for operation in self.get_airzone_value(AZD_OPERATIONS) + ] + self._attr_temperature_unit = TEMP_UNIT_LIB_TO_HASS[ + self.get_airzone_value(AZD_TEMP_UNIT) + ] + + self._async_update_attrs() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the water heater off.""" + await self._async_update_dhw_params({API_ACS_ON: 0}) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the water heater off.""" + await self._async_update_dhw_params({API_ACS_ON: 1}) + + async def async_set_operation_mode(self, operation_mode: str) -> None: + """Set new target operation mode.""" + params = OPERATION_MODE_TO_DHW_PARAMS.get(operation_mode, {}) + await self._async_update_dhw_params(params) + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + params: dict[str, Any] = {} + if ATTR_TEMPERATURE in kwargs: + params[API_ACS_SET_POINT] = kwargs[ATTR_TEMPERATURE] + await self._async_update_dhw_params(params) + + @callback + def _handle_coordinator_update(self) -> None: + """Update attributes when the coordinator updates.""" + self._async_update_attrs() + super()._handle_coordinator_update() + + @callback + def _async_update_attrs(self) -> None: + """Update water heater attributes.""" + self._attr_current_temperature = self.get_airzone_value(AZD_TEMP) + self._attr_current_operation = OPERATION_LIB_TO_HASS[ + self.get_airzone_value(AZD_OPERATION) + ] + self._attr_max_temp = self.get_airzone_value(AZD_TEMP_MAX) + self._attr_min_temp = self.get_airzone_value(AZD_TEMP_MIN) + self._attr_target_temperature = self.get_airzone_value(AZD_TEMP_SET) diff --git a/homeassistant/components/aladdin_connect/cover.py b/homeassistant/components/aladdin_connect/cover.py index f466f5f4248e6f..604ac61300d4bb 100644 --- a/homeassistant/components/aladdin_connect/cover.py +++ b/homeassistant/components/aladdin_connect/cover.py @@ -10,7 +10,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_CLOSED, STATE_CLOSING, STATE_OPENING from homeassistant.core import HomeAssistant -from homeassistant.exceptions import PlatformNotReady +from homeassistant.exceptions import HomeAssistantError, PlatformNotReady from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -75,11 +75,13 @@ async def async_will_remove_from_hass(self) -> None: async def async_close_cover(self, **kwargs: Any) -> None: """Issue close command to cover.""" - await self._acc.close_door(self._device_id, self._number) + if not await self._acc.close_door(self._device_id, self._number): + raise HomeAssistantError("Aladdin Connect API failed to close the cover") async def async_open_cover(self, **kwargs: Any) -> None: """Issue open command to cover.""" - await self._acc.open_door(self._device_id, self._number) + if not await self._acc.open_door(self._device_id, self._number): + raise HomeAssistantError("Aladdin Connect API failed to open the cover") async def async_update(self) -> None: """Update status of cover.""" diff --git a/homeassistant/components/aladdin_connect/diagnostics.py b/homeassistant/components/aladdin_connect/diagnostics.py new file mode 100644 index 00000000000000..c49d321631e2f7 --- /dev/null +++ b/homeassistant/components/aladdin_connect/diagnostics.py @@ -0,0 +1,29 @@ +"""Diagnostics support for Aladdin Connect.""" +from __future__ import annotations + +from typing import Any + +from AIOAladdinConnect import AladdinConnectClient + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import DOMAIN + +TO_REDACT = {"serial", "device_id"} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, + config_entry: ConfigEntry, +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + + acc: AladdinConnectClient = hass.data[DOMAIN][config_entry.entry_id] + + diagnostics_data = { + "doors": async_redact_data(acc.doors, TO_REDACT), + } + + return diagnostics_data diff --git a/homeassistant/components/aladdin_connect/manifest.json b/homeassistant/components/aladdin_connect/manifest.json index 3f31a833f1aa14..83f8e0167e868f 100644 --- a/homeassistant/components/aladdin_connect/manifest.json +++ b/homeassistant/components/aladdin_connect/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/aladdin_connect", "iot_class": "cloud_polling", "loggers": ["aladdin_connect"], - "requirements": ["AIOAladdinConnect==0.1.57"] + "requirements": ["AIOAladdinConnect==0.1.58"] } diff --git a/homeassistant/components/alexa/entities.py b/homeassistant/components/alexa/entities.py index 7f6331515c66b5..da0bd8b36aaa9d 100644 --- a/homeassistant/components/alexa/entities.py +++ b/homeassistant/components/alexa/entities.py @@ -707,7 +707,8 @@ def interfaces(self) -> Generator[AlexaCapability, None, None]: # AlexaEqualizerController is disabled for denonavr # since it blocks alexa from discovering any devices. - domain = entity_sources(self.hass).get(self.entity_id, {}).get("domain") + entity_info = entity_sources(self.hass).get(self.entity_id) + domain = entity_info["domain"] if entity_info else None if ( supported & media_player.MediaPlayerEntityFeature.SELECT_SOUND_MODE and domain != "denonavr" diff --git a/homeassistant/components/alexa/errors.py b/homeassistant/components/alexa/errors.py index 2c5ced62403a6a..f8e3720e16026f 100644 --- a/homeassistant/components/alexa/errors.py +++ b/homeassistant/components/alexa/errors.py @@ -90,6 +90,13 @@ class AlexaUnsupportedThermostatModeError(AlexaError): error_type = "UNSUPPORTED_THERMOSTAT_MODE" +class AlexaUnsupportedThermostatTargetStateError(AlexaError): + """Class to represent unsupported climate target state error.""" + + namespace = "Alexa.ThermostatController" + error_type = "INVALID_TARGET_STATE" + + class AlexaTempRangeError(AlexaError): """Class to represent TempRange errors.""" diff --git a/homeassistant/components/alexa/handlers.py b/homeassistant/components/alexa/handlers.py index 3e995e9ffe205a..f99b0231e4d4be 100644 --- a/homeassistant/components/alexa/handlers.py +++ b/homeassistant/components/alexa/handlers.py @@ -73,6 +73,7 @@ AlexaSecurityPanelAuthorizationRequired, AlexaTempRangeError, AlexaUnsupportedThermostatModeError, + AlexaUnsupportedThermostatTargetStateError, AlexaVideoActionNotPermittedForContentError, ) from .state_report import AlexaDirective, AlexaResponse, async_enable_proactive_mode @@ -911,7 +912,13 @@ async def async_api_adjust_target_temp( } ) else: - target_temp = float(entity.attributes[ATTR_TEMPERATURE]) + temp_delta + current_target_temp: str | None = entity.attributes.get(ATTR_TEMPERATURE) + if current_target_temp is None: + raise AlexaUnsupportedThermostatTargetStateError( + "The current target temperature is not set, " + "cannot adjust target temperature" + ) + target_temp = float(current_target_temp) + temp_delta if target_temp < min_temp or target_temp > max_temp: raise AlexaTempRangeError(hass, target_temp, min_temp, max_temp) diff --git a/homeassistant/components/alexa/state_report.py b/homeassistant/components/alexa/state_report.py index 786b2ee52275dc..f1cf13a0a7ee90 100644 --- a/homeassistant/components/alexa/state_report.py +++ b/homeassistant/components/alexa/state_report.py @@ -378,8 +378,9 @@ async def async_send_changereport_message( response_text = await response.text() - _LOGGER.debug("Sent: %s", json.dumps(message_serialized)) - _LOGGER.debug("Received (%s): %s", response.status, response_text) + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug("Sent: %s", json.dumps(message_serialized)) + _LOGGER.debug("Received (%s): %s", response.status, response_text) if response.status == HTTPStatus.ACCEPTED: return @@ -531,8 +532,9 @@ async def async_send_doorbell_event_message( response_text = await response.text() - _LOGGER.debug("Sent: %s", json.dumps(message_serialized)) - _LOGGER.debug("Received (%s): %s", response.status, response_text) + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug("Sent: %s", json.dumps(message_serialized)) + _LOGGER.debug("Received (%s): %s", response.status, response_text) if response.status == HTTPStatus.ACCEPTED: return diff --git a/homeassistant/components/amcrest/manifest.json b/homeassistant/components/amcrest/manifest.json index 75d12a3271c0e3..8b8d87092c487c 100644 --- a/homeassistant/components/amcrest/manifest.json +++ b/homeassistant/components/amcrest/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/amcrest", "iot_class": "local_polling", "loggers": ["amcrest"], - "requirements": ["amcrest==1.9.7"] + "requirements": ["amcrest==1.9.8"] } diff --git a/homeassistant/components/androidtv/manifest.json b/homeassistant/components/androidtv/manifest.json index f782db79879ca0..b8c020e6e1e95d 100644 --- a/homeassistant/components/androidtv/manifest.json +++ b/homeassistant/components/androidtv/manifest.json @@ -8,8 +8,8 @@ "iot_class": "local_polling", "loggers": ["adb_shell", "androidtv", "pure_python_adb"], "requirements": [ - "adb-shell[async]==0.4.3", - "androidtv[async]==0.0.70", + "adb-shell[async]==0.4.4", + "androidtv[async]==0.0.72", "pure-python-adb[async]==0.3.0.dev0" ] } diff --git a/homeassistant/components/apcupsd/__init__.py b/homeassistant/components/apcupsd/__init__.py index 164a908e8340e9..8d7c6b2f46d78c 100644 --- a/homeassistant/components/apcupsd/__init__.py +++ b/homeassistant/components/apcupsd/__init__.py @@ -48,7 +48,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - hass.data[DOMAIN].pop(entry.entry_id) + if unload_ok and DOMAIN in hass.data: + hass.data[DOMAIN].pop(entry.entry_id) return unload_ok diff --git a/homeassistant/components/api/__init__.py b/homeassistant/components/api/__init__.py index 7b13833ccaba1e..0cade0f81ca654 100644 --- a/homeassistant/components/api/__init__.py +++ b/homeassistant/components/api/__init__.py @@ -9,10 +9,12 @@ from aiohttp.web_exceptions import HTTPBadRequest import voluptuous as vol +from homeassistant.auth.models import User from homeassistant.auth.permissions.const import POLICY_READ from homeassistant.bootstrap import DATA_LOGGING from homeassistant.components.http import HomeAssistantView, require_admin from homeassistant.const import ( + CONTENT_TYPE_JSON, EVENT_HOMEASSISTANT_STOP, MATCH_ALL, URL_API, @@ -28,7 +30,13 @@ ) import homeassistant.core as ha from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ServiceNotFound, TemplateError, Unauthorized +from homeassistant.exceptions import ( + InvalidEntityFormatError, + InvalidStateError, + ServiceNotFound, + TemplateError, + Unauthorized, +) from homeassistant.helpers import config_validation as cv, template from homeassistant.helpers.json import json_dumps from homeassistant.helpers.service import async_get_all_descriptions @@ -189,16 +197,24 @@ class APIStatesView(HomeAssistantView): name = "api:states" @ha.callback - def get(self, request): + def get(self, request: web.Request) -> web.Response: """Get current states.""" - user = request["hass_user"] - entity_perm = user.permissions.check_entity - states = [ - state - for state in request.app["hass"].states.async_all() - if entity_perm(state.entity_id, "read") - ] - return self.json(states) + user: User = request["hass_user"] + hass: HomeAssistant = request.app["hass"] + if user.is_admin: + states = (state.as_dict_json for state in hass.states.async_all()) + else: + entity_perm = user.permissions.check_entity + states = ( + state.as_dict_json + for state in hass.states.async_all() + if entity_perm(state.entity_id, "read") + ) + response = web.Response( + body=f'[{",".join(states)}]', content_type=CONTENT_TYPE_JSON + ) + response.enable_compression() + return response class APIEntityStateView(HomeAssistantView): @@ -208,21 +224,25 @@ class APIEntityStateView(HomeAssistantView): name = "api:entity-state" @ha.callback - def get(self, request, entity_id): + def get(self, request: web.Request, entity_id: str) -> web.Response: """Retrieve state of entity.""" - user = request["hass_user"] + user: User = request["hass_user"] + hass: HomeAssistant = request.app["hass"] if not user.permissions.check_entity(entity_id, POLICY_READ): raise Unauthorized(entity_id=entity_id) - if state := request.app["hass"].states.get(entity_id): - return self.json(state) + if state := hass.states.get(entity_id): + return web.Response( + body=state.as_dict_json, + content_type=CONTENT_TYPE_JSON, + ) return self.json_message("Entity not found.", HTTPStatus.NOT_FOUND) async def post(self, request, entity_id): """Update state of entity.""" if not request["hass_user"].is_admin: raise Unauthorized(entity_id=entity_id) - hass = request.app["hass"] + hass: HomeAssistant = request.app["hass"] try: data = await request.json() except ValueError: @@ -237,13 +257,20 @@ async def post(self, request, entity_id): is_new_state = hass.states.get(entity_id) is None # Write state - hass.states.async_set( - entity_id, new_state, attributes, force_update, self.context(request) - ) + try: + hass.states.async_set( + entity_id, new_state, attributes, force_update, self.context(request) + ) + except InvalidEntityFormatError: + return self.json_message( + "Invalid entity ID specified.", HTTPStatus.BAD_REQUEST + ) + except InvalidStateError: + return self.json_message("Invalid state specified.", HTTPStatus.BAD_REQUEST) # Read the state back for our response status_code = HTTPStatus.CREATED if is_new_state else HTTPStatus.OK - resp = self.json(hass.states.get(entity_id), status_code) + resp = self.json(hass.states.get(entity_id).as_dict(), status_code) resp.headers.add("Location", f"/api/states/{entity_id}") diff --git a/homeassistant/components/apple_tv/config_flow.py b/homeassistant/components/apple_tv/config_flow.py index 8a2130faca076e..6a85ea1d1a817c 100644 --- a/homeassistant/components/apple_tv/config_flow.py +++ b/homeassistant/components/apple_tv/config_flow.py @@ -26,7 +26,6 @@ SchemaFlowFormStep, SchemaOptionsFlowHandler, ) -from homeassistant.util.network import is_ipv6_address from .const import CONF_CREDENTIALS, CONF_IDENTIFIERS, CONF_START_OFF, DOMAIN @@ -184,9 +183,9 @@ async def async_step_zeroconf( self, discovery_info: zeroconf.ZeroconfServiceInfo ) -> FlowResult: """Handle device found via zeroconf.""" - host = discovery_info.host - if is_ipv6_address(host): + if discovery_info.ip_address.version == 6: return self.async_abort(reason="ipv6_not_supported") + host = discovery_info.host self._async_abort_entries_match({CONF_ADDRESS: host}) service_type = discovery_info.type[:-1] # Remove leading . name = discovery_info.name.replace(f".{service_type}.", "") diff --git a/homeassistant/components/apprise/manifest.json b/homeassistant/components/apprise/manifest.json index 04dcef052025aa..e67192040a6b34 100644 --- a/homeassistant/components/apprise/manifest.json +++ b/homeassistant/components/apprise/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/apprise", "iot_class": "cloud_push", "loggers": ["apprise"], - "requirements": ["apprise==1.4.5"] + "requirements": ["apprise==1.5.0"] } diff --git a/homeassistant/components/aquostv/manifest.json b/homeassistant/components/aquostv/manifest.json index 011b8e67a19c57..1bac2bdfb5ff8a 100644 --- a/homeassistant/components/aquostv/manifest.json +++ b/homeassistant/components/aquostv/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/aquostv", "iot_class": "local_polling", "loggers": ["sharp_aquos_rc"], - "requirements": ["sharp-aquos-rc==0.3.2"] + "requirements": ["sharp_aquos_rc==0.3.2"] } diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 520daa9f5c21a5..f4d060ed7b82d5 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -298,6 +298,26 @@ class Pipeline: id: str = field(default_factory=ulid_util.ulid) + @classmethod + def from_json(cls, data: dict[str, Any]) -> Pipeline: + """Create an instance from a JSON serialization. + + This function was added in HA Core 2023.10, previous versions will raise + if there are unexpected items in the serialized data. + """ + return cls( + conversation_engine=data["conversation_engine"], + conversation_language=data["conversation_language"], + id=data["id"], + language=data["language"], + name=data["name"], + stt_engine=data["stt_engine"], + stt_language=data["stt_language"], + tts_engine=data["tts_engine"], + tts_language=data["tts_language"], + tts_voice=data["tts_voice"], + ) + def to_json(self) -> dict[str, Any]: """Return a JSON serializable representation for storage.""" return { @@ -1205,7 +1225,7 @@ def _create_item(self, item_id: str, data: dict) -> Pipeline: def _deserialize_item(self, data: dict) -> Pipeline: """Create an item from its serialized representation.""" - return Pipeline(**data) + return Pipeline.from_json(data) def _serialize_item(self, item_id: str, item: Pipeline) -> dict: """Return the serialized representation of an item for storing.""" diff --git a/homeassistant/components/assist_pipeline/websocket_api.py b/homeassistant/components/assist_pipeline/websocket_api.py index 57e2cc8b398c97..6d8fd02a21730f 100644 --- a/homeassistant/components/assist_pipeline/websocket_api.py +++ b/homeassistant/components/assist_pipeline/websocket_api.py @@ -332,7 +332,7 @@ async def websocket_list_languages( dialect = language_util.Dialect.parse(language_tag) languages.add(dialect.language) if pipeline_languages is not None: - pipeline_languages &= languages + pipeline_languages = language_util.intersect(pipeline_languages, languages) else: pipeline_languages = languages @@ -342,11 +342,15 @@ async def websocket_list_languages( dialect = language_util.Dialect.parse(language_tag) languages.add(dialect.language) if pipeline_languages is not None: - pipeline_languages &= languages + pipeline_languages = language_util.intersect(pipeline_languages, languages) else: pipeline_languages = languages connection.send_result( msg["id"], - {"languages": pipeline_languages}, + { + "languages": sorted(pipeline_languages) + if pipeline_languages + else pipeline_languages + }, ) diff --git a/homeassistant/components/asterisk_mbox/manifest.json b/homeassistant/components/asterisk_mbox/manifest.json index 840c48aff2aa29..8348e40ba6b9aa 100644 --- a/homeassistant/components/asterisk_mbox/manifest.json +++ b/homeassistant/components/asterisk_mbox/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/asterisk_mbox", "iot_class": "local_push", "loggers": ["asterisk_mbox"], - "requirements": ["asterisk-mbox==0.5.0"] + "requirements": ["asterisk_mbox==0.5.0"] } diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index cd2737adca398b..c5a0da71136230 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -28,5 +28,5 @@ "documentation": "https://www.home-assistant.io/integrations/august", "iot_class": "cloud_push", "loggers": ["pubnub", "yalexs"], - "requirements": ["yalexs==1.8.0", "yalexs-ble==2.2.3"] + "requirements": ["yalexs==1.9.0", "yalexs-ble==2.3.0"] } diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index f4db7831235fb1..df388e52a7f63f 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -57,9 +57,6 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import ToggleEntity from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.helpers.integration_platform import ( - async_process_integration_platform_for_component, -) from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.script import ( @@ -249,10 +246,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: LOGGER, DOMAIN, hass ) - # Process integration platforms right away since - # we will create entities before firing EVENT_COMPONENT_LOADED - await async_process_integration_platform_for_component(hass, DOMAIN) - # Register automation as valid domain for Blueprint async_get_blueprints(hass) @@ -314,6 +307,9 @@ async def reload_service_handler(service_call: ServiceCall) -> None: class BaseAutomationEntity(ToggleEntity, ABC): """Base class for automation entities.""" + _entity_component_unrecorded_attributes = frozenset( + (ATTR_LAST_TRIGGERED, ATTR_MODE, ATTR_CUR, ATTR_MAX, CONF_ID) + ) raw_config: ConfigType | None @property diff --git a/homeassistant/components/automation/blueprints/motion_light.yaml b/homeassistant/components/automation/blueprints/motion_light.yaml index 5b389a3fc266c2..8f5d3f957f990a 100644 --- a/homeassistant/components/automation/blueprints/motion_light.yaml +++ b/homeassistant/components/automation/blueprints/motion_light.yaml @@ -9,8 +9,9 @@ blueprint: name: Motion Sensor selector: entity: - domain: binary_sensor - device_class: motion + filter: + device_class: motion + domain: binary_sensor light_target: name: Light selector: diff --git a/homeassistant/components/automation/blueprints/notify_leaving_zone.yaml b/homeassistant/components/automation/blueprints/notify_leaving_zone.yaml index 0798a051173a67..e1e3bd5b2f693b 100644 --- a/homeassistant/components/automation/blueprints/notify_leaving_zone.yaml +++ b/homeassistant/components/automation/blueprints/notify_leaving_zone.yaml @@ -9,18 +9,21 @@ blueprint: name: Person selector: entity: - domain: person + filter: + domain: person zone_entity: name: Zone selector: entity: - domain: zone + filter: + domain: zone notify_device: name: Device to notify description: Device needs to run the official Home Assistant app to receive notifications. selector: device: - integration: mobile_app + filter: + integration: mobile_app trigger: platform: state diff --git a/homeassistant/components/automation/recorder.py b/homeassistant/components/automation/recorder.py deleted file mode 100644 index 3083d271d1ffb6..00000000000000 --- a/homeassistant/components/automation/recorder.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.core import HomeAssistant, callback - -from . import ATTR_CUR, ATTR_LAST_TRIGGERED, ATTR_MAX, ATTR_MODE, CONF_ID - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude extra attributes from being recorded in the database.""" - return {ATTR_LAST_TRIGGERED, ATTR_MODE, ATTR_CUR, ATTR_MAX, CONF_ID} diff --git a/homeassistant/components/awair/__init__.py b/homeassistant/components/awair/__init__.py index 083c7d48b037c1..cb974707e9389a 100644 --- a/homeassistant/components/awair/__init__.py +++ b/homeassistant/components/awair/__init__.py @@ -1,29 +1,16 @@ """The awair component.""" from __future__ import annotations -from asyncio import gather, timeout -from dataclasses import dataclass -from datetime import timedelta - -from aiohttp import ClientSession -from python_awair import Awair, AwairLocal -from python_awair.air_data import AirData -from python_awair.devices import AwairBaseDevice, AwairLocalDevice -from python_awair.exceptions import AuthError, AwairError - from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, Platform +from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed - -from .const import ( - API_TIMEOUT, - DOMAIN, - LOGGER, - UPDATE_INTERVAL_CLOUD, - UPDATE_INTERVAL_LOCAL, + +from .const import DOMAIN +from .coordinator import ( + AwairCloudDataUpdateCoordinator, + AwairDataUpdateCoordinator, + AwairLocalDataUpdateCoordinator, ) PLATFORMS = [Platform.SENSOR] @@ -70,93 +57,3 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> hass.data[DOMAIN].pop(config_entry.entry_id) return unload_ok - - -@dataclass -class AwairResult: - """Wrapper class to hold an awair device and set of air data.""" - - device: AwairBaseDevice - air_data: AirData - - -class AwairDataUpdateCoordinator(DataUpdateCoordinator[dict[str, AwairResult]]): - """Define a wrapper class to update Awair data.""" - - def __init__( - self, - hass: HomeAssistant, - config_entry: ConfigEntry, - update_interval: timedelta | None, - ) -> None: - """Set up the AwairDataUpdateCoordinator class.""" - self._config_entry = config_entry - self.title = config_entry.title - - super().__init__(hass, LOGGER, name=DOMAIN, update_interval=update_interval) - - async def _fetch_air_data(self, device: AwairBaseDevice) -> AwairResult: - """Fetch latest air quality data.""" - LOGGER.debug("Fetching data for %s", device.uuid) - air_data = await device.air_data_latest() - LOGGER.debug(air_data) - return AwairResult(device=device, air_data=air_data) - - -class AwairCloudDataUpdateCoordinator(AwairDataUpdateCoordinator): - """Define a wrapper class to update Awair data from Cloud API.""" - - def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, session: ClientSession - ) -> None: - """Set up the AwairCloudDataUpdateCoordinator class.""" - access_token = config_entry.data[CONF_ACCESS_TOKEN] - self._awair = Awair(access_token=access_token, session=session) - - super().__init__(hass, config_entry, UPDATE_INTERVAL_CLOUD) - - async def _async_update_data(self) -> dict[str, AwairResult]: - """Update data via Awair client library.""" - async with timeout(API_TIMEOUT): - try: - LOGGER.debug("Fetching users and devices") - user = await self._awair.user() - devices = await user.devices() - results = await gather( - *(self._fetch_air_data(device) for device in devices) - ) - return {result.device.uuid: result for result in results} - except AuthError as err: - raise ConfigEntryAuthFailed from err - except Exception as err: - raise UpdateFailed(err) from err - - -class AwairLocalDataUpdateCoordinator(AwairDataUpdateCoordinator): - """Define a wrapper class to update Awair data from the local API.""" - - _device: AwairLocalDevice | None = None - - def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, session: ClientSession - ) -> None: - """Set up the AwairLocalDataUpdateCoordinator class.""" - self._awair = AwairLocal( - session=session, device_addrs=[config_entry.data[CONF_HOST]] - ) - - super().__init__(hass, config_entry, UPDATE_INTERVAL_LOCAL) - - async def _async_update_data(self) -> dict[str, AwairResult]: - """Update data via Awair client library.""" - async with timeout(API_TIMEOUT): - try: - if self._device is None: - LOGGER.debug("Fetching devices") - devices = await self._awair.devices() - self._device = devices[0] - result = await self._fetch_air_data(self._device) - return {result.device.uuid: result} - except AwairError as err: - LOGGER.error("Unexpected API error: %s", err) - raise UpdateFailed(err) from err diff --git a/homeassistant/components/awair/coordinator.py b/homeassistant/components/awair/coordinator.py new file mode 100644 index 00000000000000..b687a916a2de9b --- /dev/null +++ b/homeassistant/components/awair/coordinator.py @@ -0,0 +1,116 @@ +"""DataUpdateCoordinators for awair integration.""" +from __future__ import annotations + +from asyncio import gather, timeout +from dataclasses import dataclass +from datetime import timedelta + +from aiohttp import ClientSession +from python_awair import Awair, AwairLocal +from python_awair.air_data import AirData +from python_awair.devices import AwairBaseDevice, AwairLocalDevice +from python_awair.exceptions import AuthError, AwairError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import ( + API_TIMEOUT, + DOMAIN, + LOGGER, + UPDATE_INTERVAL_CLOUD, + UPDATE_INTERVAL_LOCAL, +) + + +@dataclass +class AwairResult: + """Wrapper class to hold an awair device and set of air data.""" + + device: AwairBaseDevice + air_data: AirData + + +class AwairDataUpdateCoordinator(DataUpdateCoordinator[dict[str, AwairResult]]): + """Define a wrapper class to update Awair data.""" + + def __init__( + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + update_interval: timedelta | None, + ) -> None: + """Set up the AwairDataUpdateCoordinator class.""" + self._config_entry = config_entry + self.title = config_entry.title + + super().__init__(hass, LOGGER, name=DOMAIN, update_interval=update_interval) + + async def _fetch_air_data(self, device: AwairBaseDevice) -> AwairResult: + """Fetch latest air quality data.""" + LOGGER.debug("Fetching data for %s", device.uuid) + air_data = await device.air_data_latest() + LOGGER.debug(air_data) + return AwairResult(device=device, air_data=air_data) + + +class AwairCloudDataUpdateCoordinator(AwairDataUpdateCoordinator): + """Define a wrapper class to update Awair data from Cloud API.""" + + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, session: ClientSession + ) -> None: + """Set up the AwairCloudDataUpdateCoordinator class.""" + access_token = config_entry.data[CONF_ACCESS_TOKEN] + self._awair = Awair(access_token=access_token, session=session) + + super().__init__(hass, config_entry, UPDATE_INTERVAL_CLOUD) + + async def _async_update_data(self) -> dict[str, AwairResult]: + """Update data via Awair client library.""" + async with timeout(API_TIMEOUT): + try: + LOGGER.debug("Fetching users and devices") + user = await self._awair.user() + devices = await user.devices() + results = await gather( + *(self._fetch_air_data(device) for device in devices) + ) + return {result.device.uuid: result for result in results} + except AuthError as err: + raise ConfigEntryAuthFailed from err + except Exception as err: + raise UpdateFailed(err) from err + + +class AwairLocalDataUpdateCoordinator(AwairDataUpdateCoordinator): + """Define a wrapper class to update Awair data from the local API.""" + + _device: AwairLocalDevice | None = None + + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, session: ClientSession + ) -> None: + """Set up the AwairLocalDataUpdateCoordinator class.""" + self._awair = AwairLocal( + session=session, device_addrs=[config_entry.data[CONF_HOST]] + ) + + super().__init__(hass, config_entry, UPDATE_INTERVAL_LOCAL) + + async def _async_update_data(self) -> dict[str, AwairResult]: + """Update data via Awair client library.""" + async with timeout(API_TIMEOUT): + try: + if self._device is None: + LOGGER.debug("Fetching devices") + devices = await self._awair.devices() + self._device = devices[0] + result = await self._fetch_air_data(self._device) + return {result.device.uuid: result} + except AwairError as err: + LOGGER.error("Unexpected API error: %s", err) + raise UpdateFailed(err) from err diff --git a/homeassistant/components/awair/sensor.py b/homeassistant/components/awair/sensor.py index 279621673309d0..2a09a8d4e70378 100644 --- a/homeassistant/components/awair/sensor.py +++ b/homeassistant/components/awair/sensor.py @@ -31,7 +31,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import AwairDataUpdateCoordinator, AwairResult from .const import ( API_CO2, API_DUST, @@ -46,6 +45,7 @@ ATTRIBUTION, DOMAIN, ) +from .coordinator import AwairDataUpdateCoordinator, AwairResult DUST_ALIASES = [API_PM25, API_PM10] diff --git a/homeassistant/components/baf/config_flow.py b/homeassistant/components/baf/config_flow.py index bbae391453386b..9edb23abcf84b6 100644 --- a/homeassistant/components/baf/config_flow.py +++ b/homeassistant/components/baf/config_flow.py @@ -14,7 +14,6 @@ from homeassistant.components import zeroconf from homeassistant.const import CONF_IP_ADDRESS from homeassistant.data_entry_flow import FlowResult -from homeassistant.util.network import is_ipv6_address from .const import DOMAIN, RUN_TIMEOUT from .models import BAFDiscovery @@ -49,10 +48,10 @@ async def async_step_zeroconf( self, discovery_info: zeroconf.ZeroconfServiceInfo ) -> FlowResult: """Handle zeroconf discovery.""" + if discovery_info.ip_address.version == 6: + return self.async_abort(reason="ipv6_not_supported") properties = discovery_info.properties ip_address = discovery_info.host - if is_ipv6_address(ip_address): - return self.async_abort(reason="ipv6_not_supported") uuid = properties["uuid"] model = properties["model"] name = properties["name"] diff --git a/homeassistant/components/blink/config_flow.py b/homeassistant/components/blink/config_flow.py index 445a84f838cd61..d3b2878b52266d 100644 --- a/homeassistant/components/blink/config_flow.py +++ b/homeassistant/components/blink/config_flow.py @@ -59,7 +59,7 @@ def validate_input(auth: Auth) -> None: raise Require2FA -def _send_blink_2fa_pin(auth: Auth, pin: str) -> bool: +def _send_blink_2fa_pin(auth: Auth, pin: str | None) -> bool: """Send 2FA pin to blink servers.""" blink = Blink() blink.auth = auth @@ -122,8 +122,9 @@ async def async_step_2fa( """Handle 2FA step.""" errors = {} if user_input is not None: - pin = user_input.get(CONF_PIN) + pin: str | None = user_input.get(CONF_PIN) try: + assert self.auth valid_token = await self.hass.async_add_executor_job( _send_blink_2fa_pin, self.auth, pin ) diff --git a/homeassistant/components/bloomsky/sensor.py b/homeassistant/components/bloomsky/sensor.py index 35c9a40a46a0b6..4361af9ad37c7d 100644 --- a/homeassistant/components/bloomsky/sensor.py +++ b/homeassistant/components/bloomsky/sensor.py @@ -100,6 +100,7 @@ def __init__(self, bs, device, sensor_name): self._sensor_name = sensor_name self._attr_name = f"{device['DeviceName']} {sensor_name}" self._attr_unique_id = f"{self._device_id}-{sensor_name}" + self._attr_device_class = SENSOR_DEVICE_CLASS.get(sensor_name) self._attr_native_unit_of_measurement = SENSOR_UNITS_IMPERIAL.get( sensor_name, None ) @@ -108,11 +109,6 @@ def __init__(self, bs, device, sensor_name): sensor_name, None ) - @property - def device_class(self) -> SensorDeviceClass | None: - """Return the class of this device, from component DEVICE_CLASSES.""" - return SENSOR_DEVICE_CLASS.get(self._sensor_name) - def update(self) -> None: """Request an update from the BloomSky API.""" self._bloomsky.refresh_devices() diff --git a/homeassistant/components/bluetooth/active_update_coordinator.py b/homeassistant/components/bluetooth/active_update_coordinator.py index 5fa05b87cc8701..cdf51d34978423 100644 --- a/homeassistant/components/bluetooth/active_update_coordinator.py +++ b/homeassistant/components/bluetooth/active_update_coordinator.py @@ -110,7 +110,7 @@ def needs_poll(self, service_info: BluetoothServiceInfoBleak) -> bool: return False poll_age: float | None = None if self._last_poll: - poll_age = monotonic_time_coarse() - self._last_poll + poll_age = service_info.time - self._last_poll return self._needs_poll_method(service_info, poll_age) async def _async_poll_data( diff --git a/homeassistant/components/bluetooth/active_update_processor.py b/homeassistant/components/bluetooth/active_update_processor.py index 8e38191c820ac0..a3f5e20a9e97e3 100644 --- a/homeassistant/components/bluetooth/active_update_processor.py +++ b/homeassistant/components/bluetooth/active_update_processor.py @@ -103,7 +103,7 @@ def needs_poll(self, service_info: BluetoothServiceInfoBleak) -> bool: return False poll_age: float | None = None if self._last_poll: - poll_age = monotonic_time_coarse() - self._last_poll + poll_age = service_info.time - self._last_poll return self._needs_poll_method(service_info, poll_age) async def _async_poll_data( diff --git a/homeassistant/components/bluetooth/api.py b/homeassistant/components/bluetooth/api.py index be35a9d255d947..e364fd08e88f64 100644 --- a/homeassistant/components/bluetooth/api.py +++ b/homeassistant/components/bluetooth/api.py @@ -138,7 +138,7 @@ async def async_process_advertisements( timeout: int, ) -> BluetoothServiceInfoBleak: """Process advertisements until callback returns true or timeout expires.""" - done: Future[BluetoothServiceInfoBleak] = Future() + done: Future[BluetoothServiceInfoBleak] = hass.loop.create_future() @hass_callback def _async_discovered_device( diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 59a87f4dfbbe4e..56b06cd9d35d0c 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -14,11 +14,11 @@ ], "quality_scale": "internal", "requirements": [ - "bleak==0.20.2", - "bleak-retry-connector==3.1.1", - "bluetooth-adapters==0.16.0", - "bluetooth-auto-recovery==1.2.1", - "bluetooth-data-tools==1.9.1", - "dbus-fast==1.94.1" + "bleak==0.21.1", + "bleak-retry-connector==3.2.1", + "bluetooth-adapters==0.16.1", + "bluetooth-auto-recovery==1.2.3", + "bluetooth-data-tools==1.11.0", + "dbus-fast==2.9.0" ] } diff --git a/homeassistant/components/bluetooth/passive_update_coordinator.py b/homeassistant/components/bluetooth/passive_update_coordinator.py index 6f1749aeef2978..fcf6fcdf2551f3 100644 --- a/homeassistant/components/bluetooth/passive_update_coordinator.py +++ b/homeassistant/components/bluetooth/passive_update_coordinator.py @@ -85,6 +85,7 @@ def _async_handle_bluetooth_event( change: BluetoothChange, ) -> None: """Handle a Bluetooth event.""" + self._available = True self.async_update_listeners() diff --git a/homeassistant/components/bluetooth/passive_update_processor.py b/homeassistant/components/bluetooth/passive_update_processor.py index 20b992d06d6ed4..7294d55f912d22 100644 --- a/homeassistant/components/bluetooth/passive_update_processor.py +++ b/homeassistant/components/bluetooth/passive_update_processor.py @@ -341,7 +341,8 @@ def _async_handle_bluetooth_event( change: BluetoothChange, ) -> None: """Handle a Bluetooth event.""" - super()._async_handle_bluetooth_event(service_info, change) + was_available = self._available + self._available = True if self.hass.is_stopping: return @@ -359,7 +360,7 @@ def _async_handle_bluetooth_event( self.logger.info("Coordinator %s recovered", self.name) for processor in self._processors: - processor.async_handle_update(update) + processor.async_handle_update(update, was_available) _PassiveBluetoothDataProcessorT = TypeVar( @@ -516,20 +517,39 @@ def remove_listener() -> None: @callback def async_update_listeners( - self, data: PassiveBluetoothDataUpdate[_T] | None + self, + data: PassiveBluetoothDataUpdate[_T] | None, + was_available: bool | None = None, ) -> None: """Update all registered listeners.""" + if was_available is None: + was_available = self.coordinator.available + # Dispatch to listeners without a filter key for update_callback in self._listeners: update_callback(data) + if not was_available or data is None: + # When data is None, or was_available is False, + # dispatch to all listeners as it means the device + # is flipping between available and unavailable + for listeners in self._entity_key_listeners.values(): + for update_callback in listeners: + update_callback(data) + return + # Dispatch to listeners with a filter key - for listeners in self._entity_key_listeners.values(): - for update_callback in listeners: - update_callback(data) + # if the key is in the data + entity_key_listeners = self._entity_key_listeners + for entity_key in data.entity_data: + if maybe_listener := entity_key_listeners.get(entity_key): + for update_callback in maybe_listener: + update_callback(data) @callback - def async_handle_update(self, update: _T) -> None: + def async_handle_update( + self, update: _T, was_available: bool | None = None + ) -> None: """Handle a Bluetooth event.""" try: new_data = self.update_method(update) @@ -554,7 +574,7 @@ def async_handle_update(self, update: _T) -> None: ) self.data.update(new_data) - self.async_update_listeners(new_data) + self.async_update_listeners(new_data, was_available) class PassiveBluetoothProcessorEntity(Entity, Generic[_PassiveBluetoothDataProcessorT]): diff --git a/homeassistant/components/bluetooth/update_coordinator.py b/homeassistant/components/bluetooth/update_coordinator.py index 9c38bf2f5207cd..12bff3be645bdf 100644 --- a/homeassistant/components/bluetooth/update_coordinator.py +++ b/homeassistant/components/bluetooth/update_coordinator.py @@ -39,6 +39,8 @@ def __init__( self.mode = mode self._last_unavailable_time = 0.0 self._last_name = address + # Subclasses are responsible for setting _available to True + # when the abstractmethod _async_handle_bluetooth_event is called. self._available = async_address_present(hass, address, connectable) @callback @@ -88,23 +90,13 @@ def available(self) -> bool: """Return if the device is available.""" return self._available - @callback - def _async_handle_bluetooth_event_internal( - self, - service_info: BluetoothServiceInfoBleak, - change: BluetoothChange, - ) -> None: - """Handle a bluetooth event.""" - self._available = True - self._async_handle_bluetooth_event(service_info, change) - @callback def _async_start(self) -> None: """Start the callbacks.""" self._on_stop.append( async_register_callback( self.hass, - self._async_handle_bluetooth_event_internal, + self._async_handle_bluetooth_event, BluetoothCallbackMatcher( address=self.address, connectable=self.connectable ), diff --git a/homeassistant/components/bluetooth/wrappers.py b/homeassistant/components/bluetooth/wrappers.py index 3a0abc855b596a..97f253f882525c 100644 --- a/homeassistant/components/bluetooth/wrappers.py +++ b/homeassistant/components/bluetooth/wrappers.py @@ -120,15 +120,17 @@ def discovered_devices(self) -> list[BLEDevice]: def register_detection_callback( self, callback: AdvertisementDataCallback | None - ) -> None: + ) -> Callable[[], None]: """Register a detection callback. The callback is called when a device is discovered or has a property changed. - This method takes the callback and registers it with the long running sscanner. + This method takes the callback and registers it with the long running scanner. """ self._advertisement_data_callback = callback self._setup_detection_callback() + assert self._detection_cancel is not None + return self._detection_cancel def _setup_detection_callback(self) -> None: """Set up the detection callback.""" diff --git a/homeassistant/components/bluetooth_tracker/device_tracker.py b/homeassistant/components/bluetooth_tracker/device_tracker.py index 4bfbe72d8b5e5c..6fecc428c10b65 100644 --- a/homeassistant/components/bluetooth_tracker/device_tracker.py +++ b/homeassistant/components/bluetooth_tracker/device_tracker.py @@ -2,7 +2,6 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable from datetime import datetime, timedelta import logging from typing import Final @@ -152,7 +151,7 @@ async def async_setup_scanner( async def perform_bluetooth_update() -> None: """Discover Bluetooth devices and update status.""" _LOGGER.debug("Performing Bluetooth devices discovery and update") - tasks: list[Awaitable[None]] = [] + tasks: list[asyncio.Task[None]] = [] try: if track_new: diff --git a/homeassistant/components/bmw_connected_drive/sensor.py b/homeassistant/components/bmw_connected_drive/sensor.py index 8f5b4fb8608a4c..62854badb20e19 100644 --- a/homeassistant/components/bmw_connected_drive/sensor.py +++ b/homeassistant/components/bmw_connected_drive/sensor.py @@ -13,6 +13,7 @@ SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import LENGTH, PERCENTAGE, VOLUME, UnitOfElectricCurrent @@ -94,6 +95,7 @@ def convert_and_round( key_class="fuel_and_battery", unit_type=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, ), # --- Specific --- "mileage": BMWSensorEntityDescription( @@ -102,6 +104,7 @@ def convert_and_round( icon="mdi:speedometer", unit_type=LENGTH, value=lambda x, hass: convert_and_round(x, hass.config.units.length, 2), + state_class=SensorStateClass.TOTAL_INCREASING, ), "remaining_range_total": BMWSensorEntityDescription( key="remaining_range_total", @@ -110,6 +113,7 @@ def convert_and_round( icon="mdi:map-marker-distance", unit_type=LENGTH, value=lambda x, hass: convert_and_round(x, hass.config.units.length, 2), + state_class=SensorStateClass.MEASUREMENT, ), "remaining_range_electric": BMWSensorEntityDescription( key="remaining_range_electric", @@ -118,6 +122,7 @@ def convert_and_round( icon="mdi:map-marker-distance", unit_type=LENGTH, value=lambda x, hass: convert_and_round(x, hass.config.units.length, 2), + state_class=SensorStateClass.MEASUREMENT, ), "remaining_range_fuel": BMWSensorEntityDescription( key="remaining_range_fuel", @@ -126,6 +131,7 @@ def convert_and_round( icon="mdi:map-marker-distance", unit_type=LENGTH, value=lambda x, hass: convert_and_round(x, hass.config.units.length, 2), + state_class=SensorStateClass.MEASUREMENT, ), "remaining_fuel": BMWSensorEntityDescription( key="remaining_fuel", @@ -134,6 +140,7 @@ def convert_and_round( icon="mdi:gas-station", unit_type=VOLUME, value=lambda x, hass: convert_and_round(x, hass.config.units.volume, 2), + state_class=SensorStateClass.MEASUREMENT, ), "remaining_fuel_percent": BMWSensorEntityDescription( key="remaining_fuel_percent", @@ -141,6 +148,7 @@ def convert_and_round( key_class="fuel_and_battery", icon="mdi:gas-station", unit_type=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, ), } diff --git a/homeassistant/components/bond/entity.py b/homeassistant/components/bond/entity.py index 3b3ace989508c7..03a5f444579c7c 100644 --- a/homeassistant/components/bond/entity.py +++ b/homeassistant/components/bond/entity.py @@ -17,7 +17,7 @@ ATTR_SW_VERSION, ATTR_VIA_DEVICE, ) -from homeassistant.core import CALLBACK_TYPE, callback +from homeassistant.core import CALLBACK_TYPE, HassJob, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_call_later @@ -68,6 +68,9 @@ def __init__( self._attr_assumed_state = self._hub.is_bridge and not self._device.trust_state self._apply_state() self._bpup_polling_fallback: CALLBACK_TYPE | None = None + self._async_update_if_bpup_not_alive_job = HassJob( + self._async_update_if_bpup_not_alive + ) @property def device_info(self) -> DeviceInfo: @@ -185,7 +188,7 @@ def _async_schedule_bpup_alive_or_poll(self) -> None: self._bpup_polling_fallback = async_call_later( self.hass, _BPUP_ALIVE_SCAN_INTERVAL if alive else _FALLBACK_SCAN_INTERVAL, - self._async_update_if_bpup_not_alive, + self._async_update_if_bpup_not_alive_job, ) async def async_will_remove_from_hass(self) -> None: diff --git a/homeassistant/components/bosch_shc/strings.json b/homeassistant/components/bosch_shc/strings.json index 67462b78bec32a..90688e1373ff5d 100644 --- a/homeassistant/components/bosch_shc/strings.json +++ b/homeassistant/components/bosch_shc/strings.json @@ -10,6 +10,9 @@ }, "credentials": { "data": { + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { "password": "Password of the Smart Home Controller" } }, diff --git a/homeassistant/components/braviatv/coordinator.py b/homeassistant/components/braviatv/coordinator.py index 9b89c667b3c08c..20b30d1dd11ee1 100644 --- a/homeassistant/components/braviatv/coordinator.py +++ b/homeassistant/components/braviatv/coordinator.py @@ -191,9 +191,11 @@ async def async_update_playing(self) -> None: if self.media_uri[:8] == "extInput": self.source = playing_info.get("title") if self.media_uri[:2] == "tv": - self.media_title = playing_info.get("programTitle") - self.media_channel = playing_info.get("title") self.media_content_id = playing_info.get("dispNum") + self.media_title = ( + playing_info.get("programTitle") or self.media_content_id + ) + self.media_channel = playing_info.get("title") or self.media_content_id self.media_content_type = MediaType.CHANNEL if not playing_info: self.media_title = "Smart TV" diff --git a/homeassistant/components/bthome/manifest.json b/homeassistant/components/bthome/manifest.json index 7f53a5b5f0634c..01db154306f5e8 100644 --- a/homeassistant/components/bthome/manifest.json +++ b/homeassistant/components/bthome/manifest.json @@ -20,5 +20,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/bthome", "iot_class": "local_push", - "requirements": ["bthome-ble==3.1.0"] + "requirements": ["bthome-ble==3.1.1"] } diff --git a/homeassistant/components/buienradar/camera.py b/homeassistant/components/buienradar/camera.py index 86e650aefed7d6..439921928d65e1 100644 --- a/homeassistant/components/buienradar/camera.py +++ b/homeassistant/components/buienradar/camera.py @@ -58,6 +58,9 @@ class BuienradarCam(Camera): [0]: https://www.buienradar.nl/overbuienradar/gratis-weerdata """ + _attr_entity_registry_enabled_default = False + _attr_name = "Buienradar" + def __init__( self, latitude: float, longitude: float, delta: float, country: str ) -> None: @@ -67,8 +70,6 @@ def __init__( """ super().__init__() - self._name = "Buienradar" - # dimension (x and y) of returned radar image self._dimension = DEFAULT_DIMENSION @@ -94,12 +95,7 @@ def __init__( # deadline for image refresh - self.delta after last successful load self._deadline: datetime | None = None - self._unique_id = f"{latitude:2.6f}{longitude:2.6f}" - - @property - def name(self) -> str: - """Return the component name.""" - return self._name + self._attr_unique_id = f"{latitude:2.6f}{longitude:2.6f}" def __needs_refresh(self) -> bool: if not (self._delta and self._deadline and self._last_image): @@ -187,13 +183,3 @@ async def async_camera_image( async with self._condition: self._loading = False self._condition.notify_all() - - @property - def unique_id(self): - """Return the unique id.""" - return self._unique_id - - @property - def entity_registry_enabled_default(self) -> bool: - """Disable entity by default.""" - return False diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index e487569453fa75..96872e039e1d59 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -481,6 +481,8 @@ def is_offset_reached( class CalendarEntity(Entity): """Base class for calendar event entities.""" + _entity_component_unrecorded_attributes = frozenset({"description"}) + _alarm_unsubs: list[CALLBACK_TYPE] = [] @property diff --git a/homeassistant/components/calendar/recorder.py b/homeassistant/components/calendar/recorder.py deleted file mode 100644 index 4aba7b409cc5a1..00000000000000 --- a/homeassistant/components/calendar/recorder.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.core import HomeAssistant, callback - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude potentially large attributes from being recorded in the database.""" - return {"description"} diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 07394ca75b2921..bb5a44a530c6f0 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -449,6 +449,10 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: class Camera(Entity): """The base class for camera entities.""" + _entity_component_unrecorded_attributes = frozenset( + {"access_token", "entity_picture"} + ) + # Entity Properties _attr_brand: str | None = None _attr_frame_interval: float = MIN_STREAM_INTERVAL diff --git a/homeassistant/components/camera/recorder.py b/homeassistant/components/camera/recorder.py deleted file mode 100644 index 5c14122088100c..00000000000000 --- a/homeassistant/components/camera/recorder.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.core import HomeAssistant, callback - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude access_token and entity_picture from being recorded in the database.""" - return {"access_token", "entity_picture"} diff --git a/homeassistant/components/cast/helpers.py b/homeassistant/components/cast/helpers.py index c6a92c21fb4627..8b8862ab318bf4 100644 --- a/homeassistant/components/cast/helpers.py +++ b/homeassistant/components/cast/helpers.py @@ -214,7 +214,7 @@ def invalidate(self): All following callbacks won't be forwarded. """ - # pylint: disable=protected-access + # pylint: disable-next=protected-access if self._cast_device._cast_info.is_audio_group: self._mz_mgr.remove_multizone(self._uuid) else: diff --git a/homeassistant/components/cert_expiry/__init__.py b/homeassistant/components/cert_expiry/__init__.py index 5d1e68a951fdc7..391bb3ef8f32c7 100644 --- a/homeassistant/components/cert_expiry/__init__.py +++ b/homeassistant/components/cert_expiry/__init__.py @@ -1,22 +1,13 @@ """The cert_expiry component.""" from __future__ import annotations -from datetime import datetime, timedelta -import logging - from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.start import async_at_started -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed - -from .const import DEFAULT_PORT, DOMAIN -from .errors import TemporaryFailure, ValidationFailure -from .helper import get_cert_expiry_timestamp - -_LOGGER = logging.getLogger(__name__) -SCAN_INTERVAL = timedelta(hours=12) +from .const import DOMAIN +from .coordinator import CertExpiryDataUpdateCoordinator PLATFORMS = [Platform.SENSOR] @@ -45,37 +36,3 @@ async def _async_finish_startup(_): async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -class CertExpiryDataUpdateCoordinator(DataUpdateCoordinator[datetime | None]): - """Class to manage fetching Cert Expiry data from single endpoint.""" - - def __init__(self, hass, host, port): - """Initialize global Cert Expiry data updater.""" - self.host = host - self.port = port - self.cert_error = None - self.is_cert_valid = False - - display_port = f":{port}" if port != DEFAULT_PORT else "" - name = f"{self.host}{display_port}" - - super().__init__( - hass, _LOGGER, name=name, update_interval=SCAN_INTERVAL, always_update=False - ) - - async def _async_update_data(self) -> datetime | None: - """Fetch certificate.""" - try: - timestamp = await get_cert_expiry_timestamp(self.hass, self.host, self.port) - except TemporaryFailure as err: - raise UpdateFailed(err.args[0]) from err - except ValidationFailure as err: - self.cert_error = err - self.is_cert_valid = False - _LOGGER.error("Certificate validation error: %s [%s]", self.host, err) - return None - - self.cert_error = None - self.is_cert_valid = True - return timestamp diff --git a/homeassistant/components/cert_expiry/coordinator.py b/homeassistant/components/cert_expiry/coordinator.py new file mode 100644 index 00000000000000..6a125758f7013d --- /dev/null +++ b/homeassistant/components/cert_expiry/coordinator.py @@ -0,0 +1,51 @@ +"""DataUpdateCoordinator for cert_expiry coordinator.""" +from __future__ import annotations + +from datetime import datetime, timedelta +import logging + +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DEFAULT_PORT +from .errors import TemporaryFailure, ValidationFailure +from .helper import get_cert_expiry_timestamp + +_LOGGER = logging.getLogger(__name__) + + +class CertExpiryDataUpdateCoordinator(DataUpdateCoordinator[datetime | None]): + """Class to manage fetching Cert Expiry data from single endpoint.""" + + def __init__(self, hass, host, port): + """Initialize global Cert Expiry data updater.""" + self.host = host + self.port = port + self.cert_error = None + self.is_cert_valid = False + + display_port = f":{port}" if port != DEFAULT_PORT else "" + name = f"{self.host}{display_port}" + + super().__init__( + hass, + _LOGGER, + name=name, + update_interval=timedelta(hours=12), + always_update=False, + ) + + async def _async_update_data(self) -> datetime | None: + """Fetch certificate.""" + try: + timestamp = await get_cert_expiry_timestamp(self.hass, self.host, self.port) + except TemporaryFailure as err: + raise UpdateFailed(err.args[0]) from err + except ValidationFailure as err: + self.cert_error = err + self.is_cert_valid = False + _LOGGER.error("Certificate validation error: %s [%s]", self.host, err) + return None + + self.cert_error = None + self.is_cert_valid = True + return timestamp diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index 907ff84491bb1f..a075467a313fc9 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -209,6 +209,20 @@ class ClimateEntityDescription(EntityDescription): class ClimateEntity(Entity): """Base class for climate entities.""" + _entity_component_unrecorded_attributes = frozenset( + { + ATTR_HVAC_MODES, + ATTR_FAN_MODES, + ATTR_SWING_MODES, + ATTR_MIN_TEMP, + ATTR_MAX_TEMP, + ATTR_MIN_HUMIDITY, + ATTR_MAX_HUMIDITY, + ATTR_TARGET_TEMP_STEP, + ATTR_PRESET_MODES, + } + ) + entity_description: ClimateEntityDescription _attr_current_humidity: int | None = None _attr_current_temperature: float | None = None @@ -242,8 +256,9 @@ def state(self) -> str | None: hvac_mode = self.hvac_mode if hvac_mode is None: return None + # Support hvac_mode as string for custom integration backwards compatibility if not isinstance(hvac_mode, HVACMode): - return HVACMode(hvac_mode).value + return HVACMode(hvac_mode).value # type: ignore[unreachable] return hvac_mode.value @property @@ -458,11 +473,11 @@ def swing_modes(self) -> list[str] | None: """ return self._attr_swing_modes - def set_temperature(self, **kwargs) -> None: + def set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" raise NotImplementedError() - async def async_set_temperature(self, **kwargs) -> None: + async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" await self.hass.async_add_executor_job( ft.partial(self.set_temperature, **kwargs) diff --git a/homeassistant/components/climate/device_condition.py b/homeassistant/components/climate/device_condition.py index d9f1b240a9ae5b..57b9654651bcc8 100644 --- a/homeassistant/components/climate/device_condition.py +++ b/homeassistant/components/climate/device_condition.py @@ -92,9 +92,9 @@ def test_is_state(hass: HomeAssistant, variables: TemplateVarsType) -> bool: return False if config[CONF_TYPE] == "is_hvac_mode": - return state.state == config[const.ATTR_HVAC_MODE] + return bool(state.state == config[const.ATTR_HVAC_MODE]) - return ( + return bool( state.attributes.get(const.ATTR_PRESET_MODE) == config[const.ATTR_PRESET_MODE] ) diff --git a/homeassistant/components/climate/recorder.py b/homeassistant/components/climate/recorder.py deleted file mode 100644 index 879e6bfbbac709..00000000000000 --- a/homeassistant/components/climate/recorder.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.core import HomeAssistant, callback - -from .const import ( - ATTR_FAN_MODES, - ATTR_HVAC_MODES, - ATTR_MAX_HUMIDITY, - ATTR_MAX_TEMP, - ATTR_MIN_HUMIDITY, - ATTR_MIN_TEMP, - ATTR_PRESET_MODES, - ATTR_SWING_MODES, - ATTR_TARGET_TEMP_STEP, -) - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude static attributes from being recorded in the database.""" - return { - ATTR_HVAC_MODES, - ATTR_FAN_MODES, - ATTR_SWING_MODES, - ATTR_MIN_TEMP, - ATTR_MAX_TEMP, - ATTR_MIN_HUMIDITY, - ATTR_MAX_HUMIDITY, - ATTR_TARGET_TEMP_STEP, - ATTR_PRESET_MODES, - } diff --git a/homeassistant/components/climate/reproduce_state.py b/homeassistant/components/climate/reproduce_state.py index 0bbc6fce7ecce5..2897a956fc6907 100644 --- a/homeassistant/components/climate/reproduce_state.py +++ b/homeassistant/components/climate/reproduce_state.py @@ -38,7 +38,9 @@ async def _async_reproduce_states( ) -> None: """Reproduce component states.""" - async def call_service(service: str, keys: Iterable, data=None): + async def call_service( + service: str, keys: Iterable, data: dict[str, Any] | None = None + ) -> None: """Call service with set of attributes given.""" data = data or {} data["entity_id"] = state.entity_id diff --git a/homeassistant/components/climate/strings.json b/homeassistant/components/climate/strings.json index c517bfd7a20b06..55ccef2bc76254 100644 --- a/homeassistant/components/climate/strings.json +++ b/homeassistant/components/climate/strings.json @@ -66,7 +66,7 @@ "heating": "Heating", "cooling": "Cooling", "drying": "Drying", - "idle": "Idle", + "idle": "[%key:common::state::idle%]", "fan": "Fan" } }, @@ -93,7 +93,7 @@ "away": "Away", "boost": "Boost", "comfort": "Comfort", - "home": "Home", + "home": "[%key:common::state::home%]", "sleep": "Sleep", "activity": "Activity" } diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 40e5f264caf443..4dc242376d9d9e 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -47,7 +47,6 @@ CONF_FILTER, CONF_GOOGLE_ACTIONS, CONF_RELAYER_SERVER, - CONF_REMOTE_SNI_SERVER, CONF_REMOTESTATE_SERVER, CONF_SERVICEHANDLERS_SERVER, CONF_THINGTALK_SERVER, @@ -115,7 +114,6 @@ vol.Optional(CONF_ALEXA_SERVER): str, vol.Optional(CONF_CLOUDHOOK_SERVER): str, vol.Optional(CONF_RELAYER_SERVER): str, - vol.Optional(CONF_REMOTE_SNI_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/const.py b/homeassistant/components/cloud/const.py index 7aa39efbf07204..bd9d61cde16522 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -55,7 +55,6 @@ CONF_ALEXA_SERVER = "alexa_server" CONF_CLOUDHOOK_SERVER = "cloudhook_server" CONF_RELAYER_SERVER = "relayer_server" -CONF_REMOTE_SNI_SERVER = "remote_sni_server" CONF_REMOTESTATE_SERVER = "remotestate_server" CONF_THINGTALK_SERVER = "thingtalk_server" CONF_SERVICEHANDLERS_SERVER = "servicehandlers_server" diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index a8e28d6629124a..fe0628f1886ae2 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -8,5 +8,5 @@ "integration_type": "system", "iot_class": "cloud_push", "loggers": ["hass_nabucasa"], - "requirements": ["hass-nabucasa==0.70.0"] + "requirements": ["hass-nabucasa==0.71.0"] } diff --git a/homeassistant/components/co2signal/__init__.py b/homeassistant/components/co2signal/__init__.py index 721a26e147f9ca..04ae811197bfb3 100644 --- a/homeassistant/components/co2signal/__init__.py +++ b/homeassistant/components/co2signal/__init__.py @@ -1,45 +1,14 @@ """The CO2 Signal integration.""" from __future__ import annotations -from collections.abc import Mapping -from datetime import timedelta -import logging -from typing import Any, TypedDict, cast - -import CO2Signal - from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import CONF_COUNTRY_CODE, DOMAIN +from .const import DOMAIN +from .coordinator import CO2SignalCoordinator PLATFORMS = [Platform.SENSOR] -_LOGGER = logging.getLogger(__name__) - - -class CO2SignalData(TypedDict): - """Data field.""" - - carbonIntensity: float - fossilFuelPercentage: float - - -class CO2SignalUnit(TypedDict): - """Unit field.""" - - carbonIntensity: str - - -class CO2SignalResponse(TypedDict): - """API response.""" - - status: str - countryCode: str - data: CO2SignalData - units: CO2SignalUnit async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -55,87 +24,3 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -class CO2SignalCoordinator(DataUpdateCoordinator[CO2SignalResponse]): - """Data update coordinator.""" - - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: - """Initialize the coordinator.""" - super().__init__( - hass, _LOGGER, name=DOMAIN, update_interval=timedelta(minutes=15) - ) - self._entry = entry - - @property - def entry_id(self) -> str: - """Return entry ID.""" - return self._entry.entry_id - - async def _async_update_data(self) -> CO2SignalResponse: - """Fetch the latest data from the source.""" - try: - data = await self.hass.async_add_executor_job( - get_data, self.hass, self._entry.data - ) - except InvalidAuth as err: - raise ConfigEntryAuthFailed from err - except CO2Error as err: - raise UpdateFailed(str(err)) from err - - return data - - -class CO2Error(HomeAssistantError): - """Base error.""" - - -class InvalidAuth(CO2Error): - """Raised when invalid authentication credentials are provided.""" - - -class APIRatelimitExceeded(CO2Error): - """Raised when the API rate limit is exceeded.""" - - -class UnknownError(CO2Error): - """Raised when an unknown error occurs.""" - - -def get_data(hass: HomeAssistant, config: Mapping[str, Any]) -> CO2SignalResponse: - """Get data from the API.""" - if CONF_COUNTRY_CODE in config: - latitude = None - longitude = None - else: - latitude = config.get(CONF_LATITUDE, hass.config.latitude) - longitude = config.get(CONF_LONGITUDE, hass.config.longitude) - - try: - data = CO2Signal.get_latest( - config[CONF_API_KEY], - config.get(CONF_COUNTRY_CODE), - latitude, - longitude, - wait=False, - ) - - except ValueError as err: - err_str = str(err) - - if "Invalid authentication credentials" in err_str: - raise InvalidAuth from err - if "API rate limit exceeded." in err_str: - raise APIRatelimitExceeded from err - - _LOGGER.exception("Unexpected exception") - raise UnknownError from err - - if "error" in data: - raise UnknownError(data["error"]) - - if data.get("status") != "ok": - _LOGGER.exception("Unexpected response: %s", data) - raise UnknownError - - return cast(CO2SignalResponse, data) diff --git a/homeassistant/components/co2signal/config_flow.py b/homeassistant/components/co2signal/config_flow.py index 036282cb3e8fc3..92b09b6e17a817 100644 --- a/homeassistant/components/co2signal/config_flow.py +++ b/homeassistant/components/co2signal/config_flow.py @@ -10,8 +10,9 @@ from homeassistant.data_entry_flow import FlowResult import homeassistant.helpers.config_validation as cv -from . import APIRatelimitExceeded, InvalidAuth, get_data from .const import CONF_COUNTRY_CODE, DOMAIN +from .coordinator import get_data +from .exceptions import APIRatelimitExceeded, InvalidAuth from .util import get_extra_name TYPE_USE_HOME = "Use home location" diff --git a/homeassistant/components/co2signal/coordinator.py b/homeassistant/components/co2signal/coordinator.py new file mode 100644 index 00000000000000..dfb78326abe39b --- /dev/null +++ b/homeassistant/components/co2signal/coordinator.py @@ -0,0 +1,89 @@ +"""DataUpdateCoordinator for the co2signal integration.""" +from __future__ import annotations + +from collections.abc import Mapping +from datetime import timedelta +import logging +from typing import Any, cast + +import CO2Signal + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import CONF_COUNTRY_CODE, DOMAIN +from .exceptions import APIRatelimitExceeded, CO2Error, InvalidAuth, UnknownError +from .models import CO2SignalResponse + +_LOGGER = logging.getLogger(__name__) + + +class CO2SignalCoordinator(DataUpdateCoordinator[CO2SignalResponse]): + """Data update coordinator.""" + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, _LOGGER, name=DOMAIN, update_interval=timedelta(minutes=15) + ) + self._entry = entry + + @property + def entry_id(self) -> str: + """Return entry ID.""" + return self._entry.entry_id + + async def _async_update_data(self) -> CO2SignalResponse: + """Fetch the latest data from the source.""" + try: + data = await self.hass.async_add_executor_job( + get_data, self.hass, self._entry.data + ) + except InvalidAuth as err: + raise ConfigEntryAuthFailed from err + except CO2Error as err: + raise UpdateFailed(str(err)) from err + + return data + + +def get_data(hass: HomeAssistant, config: Mapping[str, Any]) -> CO2SignalResponse: + """Get data from the API.""" + if CONF_COUNTRY_CODE in config: + latitude = None + longitude = None + else: + latitude = config.get(CONF_LATITUDE, hass.config.latitude) + longitude = config.get(CONF_LONGITUDE, hass.config.longitude) + + try: + data = CO2Signal.get_latest( + config[CONF_API_KEY], + config.get(CONF_COUNTRY_CODE), + latitude, + longitude, + wait=False, + ) + + except ValueError as err: + err_str = str(err) + + if "Invalid authentication credentials" in err_str: + raise InvalidAuth from err + if "API rate limit exceeded." in err_str: + raise APIRatelimitExceeded from err + + _LOGGER.exception("Unexpected exception") + raise UnknownError from err + + if "error" in data: + raise UnknownError(data["error"]) + + if data.get("status") != "ok": + _LOGGER.exception("Unexpected response: %s", data) + raise UnknownError + + return cast(CO2SignalResponse, data) diff --git a/homeassistant/components/co2signal/diagnostics.py b/homeassistant/components/co2signal/diagnostics.py index 8ab09b8cb752da..db08aa4eca6aaa 100644 --- a/homeassistant/components/co2signal/diagnostics.py +++ b/homeassistant/components/co2signal/diagnostics.py @@ -8,7 +8,8 @@ from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant -from . import DOMAIN, CO2SignalCoordinator +from .const import DOMAIN +from .coordinator import CO2SignalCoordinator TO_REDACT = {CONF_API_KEY} diff --git a/homeassistant/components/co2signal/exceptions.py b/homeassistant/components/co2signal/exceptions.py new file mode 100644 index 00000000000000..cc8ee709bde3d4 --- /dev/null +++ b/homeassistant/components/co2signal/exceptions.py @@ -0,0 +1,18 @@ +"""Exceptions to the co2signal integration.""" +from homeassistant.exceptions import HomeAssistantError + + +class CO2Error(HomeAssistantError): + """Base error.""" + + +class InvalidAuth(CO2Error): + """Raised when invalid authentication credentials are provided.""" + + +class APIRatelimitExceeded(CO2Error): + """Raised when the API rate limit is exceeded.""" + + +class UnknownError(CO2Error): + """Raised when an unknown error occurs.""" diff --git a/homeassistant/components/co2signal/manifest.json b/homeassistant/components/co2signal/manifest.json index a0a3ee71a9cfd6..a4d7c55d6da207 100644 --- a/homeassistant/components/co2signal/manifest.json +++ b/homeassistant/components/co2signal/manifest.json @@ -1,9 +1,10 @@ { "domain": "co2signal", "name": "Electricity Maps", - "codeowners": [], + "codeowners": ["@jpbede"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/co2signal", + "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["CO2Signal"], "requirements": ["CO2Signal==0.4.2"] diff --git a/homeassistant/components/co2signal/models.py b/homeassistant/components/co2signal/models.py new file mode 100644 index 00000000000000..758bb15c5f0851 --- /dev/null +++ b/homeassistant/components/co2signal/models.py @@ -0,0 +1,24 @@ +"""Models to the co2signal integration.""" +from typing import TypedDict + + +class CO2SignalData(TypedDict): + """Data field.""" + + carbonIntensity: float + fossilFuelPercentage: float + + +class CO2SignalUnit(TypedDict): + """Unit field.""" + + carbonIntensity: str + + +class CO2SignalResponse(TypedDict): + """API response.""" + + status: str + countryCode: str + data: CO2SignalData + units: CO2SignalUnit diff --git a/homeassistant/components/co2signal/sensor.py b/homeassistant/components/co2signal/sensor.py index c5bc7eb4c2048b..d00bdf70d3e83b 100644 --- a/homeassistant/components/co2signal/sensor.py +++ b/homeassistant/components/co2signal/sensor.py @@ -17,8 +17,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import CO2SignalCoordinator from .const import ATTRIBUTION, DOMAIN +from .coordinator import CO2SignalCoordinator SCAN_INTERVAL = timedelta(minutes=3) diff --git a/homeassistant/components/co2signal/strings.json b/homeassistant/components/co2signal/strings.json index 01c5673d4b1b11..7dbcd2e7966e89 100644 --- a/homeassistant/components/co2signal/strings.json +++ b/homeassistant/components/co2signal/strings.json @@ -3,7 +3,7 @@ "step": { "user": { "data": { - "location": "Get data for", + "location": "[%key:common::config_flow::data::location%]", "api_key": "[%key:common::config_flow::data::access_token%]" }, "description": "Visit https://electricitymaps.com/free-tier to request a token." diff --git a/homeassistant/components/comelit/coordinator.py b/homeassistant/components/comelit/coordinator.py index beb7266c403c35..1affd5046fec81 100644 --- a/homeassistant/components/comelit/coordinator.py +++ b/homeassistant/components/comelit/coordinator.py @@ -44,7 +44,6 @@ async def _async_update_data(self) -> dict[str, Any]: raise ConfigEntryAuthFailed devices_data = await self.api.get_all_devices() - alarm_data = await self.api.get_alarm_config() await self.api.logout() - return devices_data | alarm_data + return devices_data diff --git a/homeassistant/components/compensation/manifest.json b/homeassistant/components/compensation/manifest.json index 7b59879025ee2f..e166ca716cb76f 100644 --- a/homeassistant/components/compensation/manifest.json +++ b/homeassistant/components/compensation/manifest.json @@ -4,5 +4,5 @@ "codeowners": ["@Petro31"], "documentation": "https://www.home-assistant.io/integrations/compensation", "iot_class": "calculated", - "requirements": ["numpy==1.23.2"] + "requirements": ["numpy==1.26.0"] } diff --git a/homeassistant/components/conversation/services.yaml b/homeassistant/components/conversation/services.yaml index 7b6717eec6d3e9..953db065614416 100644 --- a/homeassistant/components/conversation/services.yaml +++ b/homeassistant/components/conversation/services.yaml @@ -14,3 +14,14 @@ process: example: homeassistant selector: conversation_agent: + +reload: + fields: + language: + example: NL + selector: + text: + agent_id: + example: homeassistant + selector: + conversation_agent: diff --git a/homeassistant/components/conversation/strings.json b/homeassistant/components/conversation/strings.json index 15e783c0d90afb..8240cfa3f82cdc 100644 --- a/homeassistant/components/conversation/strings.json +++ b/homeassistant/components/conversation/strings.json @@ -18,6 +18,20 @@ "description": "Conversation agent to process your request. The conversation agent is the brains of your assistant. It processes the incoming text commands." } } + }, + "reload": { + "name": "[%key:common::action::reload%]", + "description": "Reloads the intent configuration.", + "fields": { + "language": { + "name": "[%key:component::conversation::services::process::fields::language::name%]", + "description": "Language to clear cached intents for. Defaults to server language." + }, + "agent_id": { + "name": "[%key:component::conversation::services::process::fields::agent_id::name%]", + "description": "Conversation agent to reload." + } + } } } } diff --git a/homeassistant/components/coolmaster/__init__.py b/homeassistant/components/coolmaster/__init__.py index 289e70e80670d4..eaca8949b14292 100644 --- a/homeassistant/components/coolmaster/__init__.py +++ b/homeassistant/components/coolmaster/__init__.py @@ -1,18 +1,13 @@ """The Coolmaster integration.""" -import logging - from pycoolmasternet_async import CoolMasterNet -from homeassistant.components.climate import SCAN_INTERVAL 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.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import CONF_SWING_SUPPORT, DATA_COORDINATOR, DATA_INFO, DOMAIN - -_LOGGER = logging.getLogger(__name__) +from .coordinator import CoolmasterDataUpdateCoordinator PLATFORMS = [Platform.CLIMATE, Platform.BINARY_SENSOR, Platform.BUTTON, Platform.SENSOR] @@ -60,25 +55,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if unload_ok: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -class CoolmasterDataUpdateCoordinator(DataUpdateCoordinator): - """Class to manage fetching Coolmaster data.""" - - def __init__(self, hass, coolmaster): - """Initialize global Coolmaster data updater.""" - self._coolmaster = coolmaster - - super().__init__( - hass, - _LOGGER, - name=DOMAIN, - update_interval=SCAN_INTERVAL, - ) - - async def _async_update_data(self): - """Fetch data from Coolmaster.""" - try: - return await self._coolmaster.status() - except OSError as error: - raise UpdateFailed from error diff --git a/homeassistant/components/coolmaster/coordinator.py b/homeassistant/components/coolmaster/coordinator.py new file mode 100644 index 00000000000000..241f287e297518 --- /dev/null +++ b/homeassistant/components/coolmaster/coordinator.py @@ -0,0 +1,31 @@ +"""DataUpdateCoordinator for coolmaster integration.""" +import logging + +from homeassistant.components.climate import SCAN_INTERVAL +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class CoolmasterDataUpdateCoordinator(DataUpdateCoordinator): + """Class to manage fetching Coolmaster data.""" + + def __init__(self, hass, coolmaster): + """Initialize global Coolmaster data updater.""" + self._coolmaster = coolmaster + + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + + async def _async_update_data(self): + """Fetch data from Coolmaster.""" + try: + return await self._coolmaster.status() + except OSError as error: + raise UpdateFailed from error diff --git a/homeassistant/components/datetime/__init__.py b/homeassistant/components/datetime/__init__.py index b04008672aedf1..e25f4535d0c1a1 100644 --- a/homeassistant/components/datetime/__init__.py +++ b/homeassistant/components/datetime/__init__.py @@ -12,7 +12,6 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import config_validation as cv from homeassistant.helpers.config_validation import ( # noqa: F401 - ENTITY_SERVICE_FIELDS, PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, ) @@ -53,7 +52,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: SERVICE_SET_VALUE, { vol.Required(ATTR_DATETIME): cv.datetime, - **ENTITY_SERVICE_FIELDS, }, _async_set_value, ) diff --git a/homeassistant/components/debugpy/manifest.json b/homeassistant/components/debugpy/manifest.json index 4fe141c4943a7a..d3ed35643441d6 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.6.7"] + "requirements": ["debugpy==1.8.0"] } diff --git a/homeassistant/components/device_tracker/device_trigger.py b/homeassistant/components/device_tracker/device_trigger.py index a96f9affb1dd32..404dad0d4d1cc2 100644 --- a/homeassistant/components/device_tracker/device_trigger.py +++ b/homeassistant/components/device_tracker/device_trigger.py @@ -1,6 +1,7 @@ """Provides device automations for Device Tracker.""" from __future__ import annotations +from operator import attrgetter from typing import Final import voluptuous as vol @@ -98,7 +99,7 @@ async def async_get_trigger_capabilities( """List trigger capabilities.""" zones = { ent.entity_id: ent.name - for ent in sorted(hass.states.async_all(DOMAIN_ZONE), key=lambda ent: ent.name) + for ent in sorted(hass.states.async_all(DOMAIN_ZONE), key=attrgetter("name")) } return { "extra_fields": vol.Schema( diff --git a/homeassistant/components/devolo_home_control/climate.py b/homeassistant/components/devolo_home_control/climate.py index 227b479688384d..e27d5a315a5fe7 100644 --- a/homeassistant/components/devolo_home_control/climate.py +++ b/homeassistant/components/devolo_home_control/climate.py @@ -50,6 +50,13 @@ async def async_setup_entry( class DevoloClimateDeviceEntity(DevoloMultiLevelSwitchDeviceEntity, ClimateEntity): """Representation of a climate/thermostat device within devolo Home Control.""" + _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _attr_target_temperature_step = PRECISION_HALVES + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_precision = PRECISION_TENTHS + _attr_hvac_mode = HVACMode.HEAT + _attr_hvac_modes = [HVACMode.HEAT] + def __init__( self, homecontrol: HomeControl, device_instance: Zwave, element_uid: str ) -> None: @@ -60,14 +67,8 @@ def __init__( element_uid=element_uid, ) - self._attr_hvac_mode = HVACMode.HEAT - self._attr_hvac_modes = [HVACMode.HEAT] self._attr_min_temp = self._multi_level_switch_property.min self._attr_max_temp = self._multi_level_switch_property.max - self._attr_precision = PRECISION_TENTHS - self._attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE - self._attr_target_temperature_step = PRECISION_HALVES - self._attr_temperature_unit = UnitOfTemperature.CELSIUS @property def current_temperature(self) -> float | None: diff --git a/homeassistant/components/devolo_home_control/cover.py b/homeassistant/components/devolo_home_control/cover.py index a23c3fde585eea..b76948bcee7e9f 100644 --- a/homeassistant/components/devolo_home_control/cover.py +++ b/homeassistant/components/devolo_home_control/cover.py @@ -3,9 +3,6 @@ from typing import Any -from devolo_home_control_api.devices.zwave import Zwave -from devolo_home_control_api.homecontrol import HomeControl - from homeassistant.components.cover import ( CoverDeviceClass, CoverEntity, @@ -43,22 +40,12 @@ async def async_setup_entry( class DevoloCoverDeviceEntity(DevoloMultiLevelSwitchDeviceEntity, CoverEntity): """Representation of a cover device within devolo Home Control.""" - def __init__( - self, homecontrol: HomeControl, device_instance: Zwave, element_uid: str - ) -> None: - """Initialize a climate entity within devolo Home Control.""" - super().__init__( - homecontrol=homecontrol, - device_instance=device_instance, - element_uid=element_uid, - ) - - self._attr_device_class = CoverDeviceClass.BLIND - self._attr_supported_features = ( - CoverEntityFeature.OPEN - | CoverEntityFeature.CLOSE - | CoverEntityFeature.SET_POSITION - ) + _attr_supported_features = ( + CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.SET_POSITION + ) + _attr_device_class = CoverDeviceClass.BLIND @property def current_cover_position(self) -> int: diff --git a/homeassistant/components/devolo_home_control/light.py b/homeassistant/components/devolo_home_control/light.py index 93a66e345ecb2b..e91466c7ecec68 100644 --- a/homeassistant/components/devolo_home_control/light.py +++ b/homeassistant/components/devolo_home_control/light.py @@ -39,6 +39,8 @@ async def async_setup_entry( class DevoloLightDeviceEntity(DevoloMultiLevelSwitchDeviceEntity, LightEntity): """Representation of a light within devolo Home Control.""" + _attr_color_mode = ColorMode.BRIGHTNESS + def __init__( self, homecontrol: HomeControl, device_instance: Zwave, element_uid: str ) -> None: @@ -49,7 +51,6 @@ def __init__( element_uid=element_uid, ) - self._attr_color_mode = ColorMode.BRIGHTNESS self._attr_supported_color_modes = {ColorMode.BRIGHTNESS} self._binary_switch_property = device_instance.binary_switch_property.get( element_uid.replace("Dimmer", "BinarySwitch") diff --git a/homeassistant/components/devolo_home_control/sensor.py b/homeassistant/components/devolo_home_control/sensor.py index b7e2a30b4c1bd8..fa11424ae94b29 100644 --- a/homeassistant/components/devolo_home_control/sensor.py +++ b/homeassistant/components/devolo_home_control/sensor.py @@ -123,6 +123,12 @@ def __init__( class DevoloBatteryEntity(DevoloMultiLevelDeviceEntity): """Representation of a battery entity within devolo Home Control.""" + _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_native_unit_of_measurement = PERCENTAGE + _attr_name = "Battery level" + _attr_device_class = SensorDeviceClass.BATTERY + _attr_state_class = SensorStateClass.MEASUREMENT + def __init__( self, homecontrol: HomeControl, device_instance: Zwave, element_uid: str ) -> None: @@ -134,11 +140,6 @@ def __init__( element_uid=element_uid, ) - self._attr_device_class = DEVICE_CLASS_MAPPING.get("battery") - self._attr_state_class = STATE_CLASS_MAPPING.get("battery") - self._attr_entity_category = EntityCategory.DIAGNOSTIC - self._attr_native_unit_of_measurement = PERCENTAGE - self._attr_name = "Battery level" self._value = device_instance.battery_level @@ -175,7 +176,11 @@ def __init__( @property def unique_id(self) -> str: - """Return the unique ID of the entity.""" + """Return the unique ID of the entity. + + As both sensor types share the same element_uid we need to extend original + self._attr_unique_id to be really unique. + """ return f"{self._attr_unique_id}_{self._sensor_type}" def _sync(self, message: tuple) -> None: diff --git a/homeassistant/components/devolo_home_control/switch.py b/homeassistant/components/devolo_home_control/switch.py index 9b96e58da60062..c442cc55763c04 100644 --- a/homeassistant/components/devolo_home_control/switch.py +++ b/homeassistant/components/devolo_home_control/switch.py @@ -46,7 +46,7 @@ class DevoloSwitch(DevoloDeviceEntity, SwitchEntity): def __init__( self, homecontrol: HomeControl, device_instance: Zwave, element_uid: str ) -> None: - """Initialize an devolo Switch.""" + """Initialize a devolo Switch.""" super().__init__( homecontrol=homecontrol, device_instance=device_instance, diff --git a/homeassistant/components/devolo_home_network/__init__.py b/homeassistant/components/devolo_home_network/__init__.py index f54fddc9a86a4e..d76a6163516f26 100644 --- a/homeassistant/components/devolo_home_network/__init__.py +++ b/homeassistant/components/devolo_home_network/__init__.py @@ -1,7 +1,6 @@ """The devolo Home Network integration.""" from __future__ import annotations -import asyncio import logging from typing import Any @@ -74,8 +73,7 @@ async def async_update_firmware_available() -> UpdateFirmwareCheck: """Fetch data from API endpoint.""" assert device.device try: - async with asyncio.timeout(10): - return await device.device.async_check_firmware_available() + return await device.device.async_check_firmware_available() except DeviceUnavailable as err: raise UpdateFailed(err) from err @@ -83,8 +81,7 @@ async def async_update_connected_plc_devices() -> LogicalNetwork: """Fetch data from API endpoint.""" assert device.plcnet try: - async with asyncio.timeout(10): - return await device.plcnet.async_get_network_overview() + return await device.plcnet.async_get_network_overview() except DeviceUnavailable as err: raise UpdateFailed(err) from err @@ -92,8 +89,7 @@ async def async_update_guest_wifi_status() -> WifiGuestAccessGet: """Fetch data from API endpoint.""" assert device.device try: - async with asyncio.timeout(10): - return await device.device.async_get_wifi_guest_access() + return await device.device.async_get_wifi_guest_access() except DeviceUnavailable as err: raise UpdateFailed(err) from err except DevicePasswordProtected as err: @@ -103,8 +99,7 @@ async def async_update_led_status() -> bool: """Fetch data from API endpoint.""" assert device.device try: - async with asyncio.timeout(10): - return await device.device.async_get_led_setting() + return await device.device.async_get_led_setting() except DeviceUnavailable as err: raise UpdateFailed(err) from err @@ -112,8 +107,7 @@ async def async_update_wifi_connected_station() -> list[ConnectedStationInfo]: """Fetch data from API endpoint.""" assert device.device try: - async with asyncio.timeout(10): - return await device.device.async_get_wifi_connected_station() + return await device.device.async_get_wifi_connected_station() except DeviceUnavailable as err: raise UpdateFailed(err) from err @@ -121,8 +115,7 @@ async def async_update_wifi_neighbor_access_points() -> list[NeighborAPInfo]: """Fetch data from API endpoint.""" assert device.device try: - async with asyncio.timeout(30): - return await device.device.async_get_wifi_neighbor_access_points() + return await device.device.async_get_wifi_neighbor_access_points() except DeviceUnavailable as err: raise UpdateFailed(err) from err diff --git a/homeassistant/components/devolo_home_network/manifest.json b/homeassistant/components/devolo_home_network/manifest.json index a047437e98028c..27fd08898c06e6 100644 --- a/homeassistant/components/devolo_home_network/manifest.json +++ b/homeassistant/components/devolo_home_network/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_polling", "loggers": ["devolo_plc_api"], "quality_scale": "platinum", - "requirements": ["devolo-plc-api==1.4.0"], + "requirements": ["devolo-plc-api==1.4.1"], "zeroconf": [ { "type": "_dvl-deviceapi._tcp.local.", diff --git a/homeassistant/components/devolo_home_network/update.py b/homeassistant/components/devolo_home_network/update.py index 21f6edd862c5f8..1c95c4262b2dc9 100644 --- a/homeassistant/components/devolo_home_network/update.py +++ b/homeassistant/components/devolo_home_network/update.py @@ -92,7 +92,6 @@ def __init__( """Initialize entity.""" self.entity_description = description super().__init__(entry, coordinator, device) - self._attr_translation_key = None self._in_progress_old_version: str | None = None @property @@ -124,7 +123,7 @@ async def async_install( except DevicePasswordProtected as ex: self.entry.async_start_reauth(self.hass) raise HomeAssistantError( - f"Device {self.entry.title} require re-authenticatication to set or change the password" + f"Device {self.entry.title} require re-authentication to set or change the password" ) from ex except DeviceUnavailable as ex: raise HomeAssistantError( diff --git a/homeassistant/components/dhcp/__init__.py b/homeassistant/components/dhcp/__init__.py index 29b25d0781be1f..c3705dad3ddbbe 100644 --- a/homeassistant/components/dhcp/__init__.py +++ b/homeassistant/components/dhcp/__init__.py @@ -58,7 +58,6 @@ from homeassistant.helpers.typing import ConfigType, EventType from homeassistant.loader import DHCPMatcher, async_get_dhcp from homeassistant.util.async_ import run_callback_threadsafe -from homeassistant.util.network import is_invalid, is_link_local, is_loopback from .const import DOMAIN @@ -162,9 +161,9 @@ def async_process_client( made_ip_address = make_ip_address(ip_address) if ( - is_link_local(made_ip_address) - or is_loopback(made_ip_address) - or is_invalid(made_ip_address) + made_ip_address.is_link_local + or made_ip_address.is_loopback + or made_ip_address.is_unspecified ): # Ignore self assigned addresses, loopback, invalid return diff --git a/homeassistant/components/dhcp/manifest.json b/homeassistant/components/dhcp/manifest.json index e65966fbaa29b0..3d9a55780457c5 100644 --- a/homeassistant/components/dhcp/manifest.json +++ b/homeassistant/components/dhcp/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_push", "loggers": ["aiodiscover", "dnspython", "pyroute2", "scapy"], "quality_scale": "internal", - "requirements": ["scapy==2.5.0", "aiodiscover==1.4.16"] + "requirements": ["scapy==2.5.0", "aiodiscover==1.5.1"] } diff --git a/homeassistant/components/discovergy/__init__.py b/homeassistant/components/discovergy/__init__.py index ab892cd9324e87..32f696a04ceadf 100644 --- a/homeassistant/components/discovergy/__init__.py +++ b/homeassistant/components/discovergy/__init__.py @@ -62,7 +62,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # so we have data when entities are added coordinator = DiscovergyUpdateCoordinator( hass=hass, - config_entry=entry, meter=meter, discovergy_client=discovergy_data.api_client, ) diff --git a/homeassistant/components/discovergy/config_flow.py b/homeassistant/components/discovergy/config_flow.py index 3434b1dd84caa5..e035661db100bf 100644 --- a/homeassistant/components/discovergy/config_flow.py +++ b/homeassistant/components/discovergy/config_flow.py @@ -85,7 +85,7 @@ async def _validate_and_save( httpx_client=get_async_client(self.hass), authentication=BasicAuth(), ).meters() - except discovergyError.HTTPError: + except (discovergyError.HTTPError, discovergyError.DiscovergyClientError): errors["base"] = "cannot_connect" except discovergyError.InvalidLogin: errors["base"] = "invalid_auth" diff --git a/homeassistant/components/discovergy/coordinator.py b/homeassistant/components/discovergy/coordinator.py index d2548d0bacd335..5f27c6a43d2ad1 100644 --- a/homeassistant/components/discovergy/coordinator.py +++ b/homeassistant/components/discovergy/coordinator.py @@ -5,10 +5,9 @@ import logging from pydiscovergy import Discovergy -from pydiscovergy.error import AccessTokenExpired, HTTPError +from pydiscovergy.error import DiscovergyClientError, HTTPError, InvalidLogin from pydiscovergy.models import Meter, Reading -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -21,19 +20,16 @@ class DiscovergyUpdateCoordinator(DataUpdateCoordinator[Reading]): """The Discovergy update coordinator.""" - config_entry: ConfigEntry discovergy_client: Discovergy meter: Meter def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, meter: Meter, discovergy_client: Discovergy, ) -> None: """Initialize the Discovergy coordinator.""" - self.config_entry = config_entry self.meter = meter self.discovergy_client = discovergy_client @@ -48,11 +44,11 @@ async def _async_update_data(self) -> Reading: """Get last reading for meter.""" try: return await self.discovergy_client.meter_last_reading(self.meter.meter_id) - except AccessTokenExpired as err: + except InvalidLogin as err: raise ConfigEntryAuthFailed( f"Auth expired while fetching last reading for meter {self.meter.meter_id}" ) from err - except HTTPError as err: + except (HTTPError, DiscovergyClientError) as err: raise UpdateFailed( f"Error while fetching last reading for meter {self.meter.meter_id}" ) from err diff --git a/homeassistant/components/dlink/strings.json b/homeassistant/components/dlink/strings.json index ee7abb3e97907d..8c60d59fa6b2f5 100644 --- a/homeassistant/components/dlink/strings.json +++ b/homeassistant/components/dlink/strings.json @@ -4,21 +4,27 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]", - "password": "Password (default: PIN code on the back)", + "password": "[%key:common::config_flow::data::password%]", "username": "[%key:common::config_flow::data::username%]", "use_legacy_protocol": "Use legacy protocol" + }, + "data_description": { + "password": "Default: PIN code on the back." } }, "confirm_discovery": { "data": { - "password": "[%key:component::dlink::config::step::user::data::password%]", + "password": "[%key:common::config_flow::data::password%]", "username": "[%key:common::config_flow::data::username%]", "use_legacy_protocol": "[%key:component::dlink::config::step::user::data::use_legacy_protocol%]" + }, + "data_description": { + "password": "[%key:component::dlink::config::step::user::data_description::password%]" } } }, "error": { - "cannot_connect": "Failed to connect/authenticate", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { diff --git a/homeassistant/components/dlna_dmr/manifest.json b/homeassistant/components/dlna_dmr/manifest.json index 23c45b73ec5b00..53bda449465e62 100644 --- a/homeassistant/components/dlna_dmr/manifest.json +++ b/homeassistant/components/dlna_dmr/manifest.json @@ -8,7 +8,7 @@ "documentation": "https://www.home-assistant.io/integrations/dlna_dmr", "iot_class": "local_push", "loggers": ["async_upnp_client"], - "requirements": ["async-upnp-client==0.35.0", "getmac==0.8.2"], + "requirements": ["async-upnp-client==0.35.1", "getmac==0.8.2"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1", diff --git a/homeassistant/components/dlna_dmr/media_player.py b/homeassistant/components/dlna_dmr/media_player.py index 50877756d521fc..3a57ba2c8ced22 100644 --- a/homeassistant/components/dlna_dmr/media_player.py +++ b/homeassistant/components/dlna_dmr/media_player.py @@ -129,6 +129,9 @@ class DlnaDmrEntity(MediaPlayerEntity): # determine whether further device polling is required. _attr_should_poll = True + # Name of the current sound mode, not supported by DLNA + _attr_sound_mode = None + def __init__( self, udn: str, @@ -745,11 +748,6 @@ async def async_set_repeat(self, repeat: RepeatMode) -> None: "Couldn't find a suitable mode for shuffle=%s, repeat=%s", shuffle, repeat ) - @property - def sound_mode(self) -> str | None: - """Name of the current sound mode, not supported by DLNA.""" - return None - @property def sound_mode_list(self) -> list[str] | None: """List of available sound modes.""" diff --git a/homeassistant/components/dlna_dms/manifest.json b/homeassistant/components/dlna_dms/manifest.json index 2adb2e7634723f..d7a72a5341113f 100644 --- a/homeassistant/components/dlna_dms/manifest.json +++ b/homeassistant/components/dlna_dms/manifest.json @@ -8,7 +8,7 @@ "documentation": "https://www.home-assistant.io/integrations/dlna_dms", "iot_class": "local_polling", "quality_scale": "platinum", - "requirements": ["async-upnp-client==0.35.0"], + "requirements": ["async-upnp-client==0.35.1"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaServer:1", diff --git a/homeassistant/components/doods/image_processing.py b/homeassistant/components/doods/image_processing.py index c94fff1124e6e5..ba97dbe38ecaf5 100644 --- a/homeassistant/components/doods/image_processing.py +++ b/homeassistant/components/doods/image_processing.py @@ -392,6 +392,10 @@ def process_image(self, image): else: paths.append(path_template) self._save_image(image, matches, paths) + else: + _LOGGER.debug( + "Not saving image(s), no detections found or no output file configured" + ) self._matches = matches self._total_matches = total_matches diff --git a/homeassistant/components/doorbird/config_flow.py b/homeassistant/components/doorbird/config_flow.py index 56a02f490421be..983e56e64da5ab 100644 --- a/homeassistant/components/doorbird/config_flow.py +++ b/homeassistant/components/doorbird/config_flow.py @@ -2,7 +2,6 @@ from __future__ import annotations from http import HTTPStatus -from ipaddress import ip_address import logging from typing import Any @@ -15,7 +14,6 @@ from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult -from homeassistant.util.network import is_ipv4_address, is_link_local from .const import CONF_EVENTS, DOMAIN, DOORBIRD_OUI from .util import get_mac_address_from_door_station_info @@ -106,16 +104,16 @@ async def async_step_zeroconf( ) -> FlowResult: """Prepare configuration for a discovered doorbird device.""" macaddress = discovery_info.properties["macaddress"] - host = discovery_info.host if macaddress[:6] != DOORBIRD_OUI: return self.async_abort(reason="not_doorbird_device") - if is_link_local(ip_address(host)): + if discovery_info.ip_address.is_link_local: return self.async_abort(reason="link_local_address") - if not is_ipv4_address(host): + if discovery_info.ip_address.version != 4: return self.async_abort(reason="not_ipv4_address") await self.async_set_unique_id(macaddress) + host = discovery_info.host self._abort_if_unique_id_configured(updates={CONF_HOST: host}) self._async_abort_entries_match({CONF_HOST: host}) diff --git a/homeassistant/components/duotecno/manifest.json b/homeassistant/components/duotecno/manifest.json index d26d4fce61e656..be2a74f884f42d 100644 --- a/homeassistant/components/duotecno/manifest.json +++ b/homeassistant/components/duotecno/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/duotecno", "iot_class": "local_push", - "requirements": ["pyduotecno==2023.8.4"] + "requirements": ["pyDuotecno==2023.8.4"] } diff --git a/homeassistant/components/dynalite/cover.py b/homeassistant/components/dynalite/cover.py index 96a1f41f9e3a2c..2bac51e0b8b67f 100644 --- a/homeassistant/components/dynalite/cover.py +++ b/homeassistant/components/dynalite/cover.py @@ -12,6 +12,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.enum import try_parse_enum +from .bridge import DynaliteBridge from .dynalitebase import DynaliteBase, async_setup_entry_base @@ -23,7 +24,7 @@ async def async_setup_entry( """Record the async_add_entities function to add them later when received from Dynalite.""" @callback - def cover_from_device(device, bridge): + def cover_from_device(device: Any, bridge: DynaliteBridge) -> CoverEntity: if device.has_tilt: return DynaliteCoverWithTilt(device, bridge) return DynaliteCover(device, bridge) @@ -36,11 +37,11 @@ def cover_from_device(device, bridge): class DynaliteCover(DynaliteBase, CoverEntity): """Representation of a Dynalite Channel as a Home Assistant Cover.""" - @property - def device_class(self) -> CoverDeviceClass: - """Return the class of the device.""" + def __init__(self, device: Any, bridge: DynaliteBridge) -> None: + """Initialize the cover.""" + super().__init__(device, bridge) device_class = try_parse_enum(CoverDeviceClass, self._device.device_class) - return device_class or CoverDeviceClass.SHUTTER + self._attr_device_class = device_class or CoverDeviceClass.SHUTTER @property def current_cover_position(self) -> int: diff --git a/homeassistant/components/dynalite/dynalitebase.py b/homeassistant/components/dynalite/dynalitebase.py index 43a4a5b106bf55..baf4c12a4c5690 100644 --- a/homeassistant/components/dynalite/dynalitebase.py +++ b/homeassistant/components/dynalite/dynalitebase.py @@ -70,7 +70,7 @@ def device_info(self) -> DeviceInfo: ) async def async_added_to_hass(self) -> None: - """Added to hass so need to restore state and register to dispatch.""" + """Handle addition to hass: restore state and register to dispatch.""" # register for device specific update await super().async_added_to_hass() diff --git a/homeassistant/components/ecobee/climate.py b/homeassistant/components/ecobee/climate.py index b18f646add78d4..e1253b585acee8 100644 --- a/homeassistant/components/ecobee/climate.py +++ b/homeassistant/components/ecobee/climate.py @@ -326,6 +326,7 @@ def __init__( self._attr_unique_id = self.thermostat["identifier"] self.vacation = None self._last_active_hvac_mode = HVACMode.HEAT_COOL + self._last_hvac_mode_before_aux_heat = HVACMode.HEAT_COOL self._attr_hvac_modes = [] if self.settings["heatStages"] or self.settings["hasHeatPump"]: @@ -541,13 +542,14 @@ def is_aux_heat(self) -> bool: def turn_aux_heat_on(self) -> None: """Turn auxiliary heater on.""" _LOGGER.debug("Setting HVAC mode to auxHeatOnly to turn on aux heat") + self._last_hvac_mode_before_aux_heat = self.hvac_mode self.data.ecobee.set_hvac_mode(self.thermostat_index, ECOBEE_AUX_HEAT_ONLY) self.update_without_throttle = True def turn_aux_heat_off(self) -> None: """Turn auxiliary heater off.""" _LOGGER.debug("Setting HVAC mode to last mode to disable aux heat") - self.set_hvac_mode(self._last_active_hvac_mode) + self.set_hvac_mode(self._last_hvac_mode_before_aux_heat) self.update_without_throttle = True def set_preset_mode(self, preset_mode: str) -> None: diff --git a/homeassistant/components/ecobee/manifest.json b/homeassistant/components/ecobee/manifest.json index 43f22e2b4d6172..71f5e04f75a7d4 100644 --- a/homeassistant/components/ecobee/manifest.json +++ b/homeassistant/components/ecobee/manifest.json @@ -5,12 +5,15 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/ecobee", "homekit": { - "models": ["EB-*", "ecobee*"] + "models": ["EB", "ecobee*"] }, "iot_class": "cloud_polling", "loggers": ["pyecobee"], "requirements": ["python-ecobee-api==0.2.14"], "zeroconf": [ + { + "type": "_ecobee._tcp.local." + }, { "type": "_sideplay._tcp.local.", "properties": { diff --git a/homeassistant/components/ecoforest/__init__.py b/homeassistant/components/ecoforest/__init__.py new file mode 100644 index 00000000000000..cc5575248fe83b --- /dev/null +++ b/homeassistant/components/ecoforest/__init__.py @@ -0,0 +1,59 @@ +"""The Ecoforest integration.""" +from __future__ import annotations + +import logging + +import httpx +from pyecoforest.api import EcoforestApi +from pyecoforest.exceptions import ( + EcoforestAuthenticationRequired, + EcoforestConnectionError, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import DOMAIN +from .coordinator import EcoforestCoordinator + +PLATFORMS: list[Platform] = [Platform.SENSOR] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Ecoforest from a config entry.""" + + host = entry.data[CONF_HOST] + auth = httpx.BasicAuth(entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD]) + api = EcoforestApi(host, auth) + + try: + device = await api.get() + _LOGGER.debug("Ecoforest: %s", device) + except EcoforestAuthenticationRequired: + _LOGGER.error("Authentication on device %s failed", host) + return False + except EcoforestConnectionError as err: + _LOGGER.error("Error communicating with device %s", host) + raise ConfigEntryNotReady from err + + coordinator = EcoforestCoordinator(hass, api) + + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/ecoforest/config_flow.py b/homeassistant/components/ecoforest/config_flow.py new file mode 100644 index 00000000000000..0afc46c23708bb --- /dev/null +++ b/homeassistant/components/ecoforest/config_flow.py @@ -0,0 +1,63 @@ +"""Config flow for Ecoforest integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from httpx import BasicAuth +from pyecoforest.api import EcoforestApi +from pyecoforest.exceptions import EcoforestAuthenticationRequired +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN, MANUFACTURER + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Ecoforest.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + + if user_input is not None: + try: + api = EcoforestApi( + user_input[CONF_HOST], + BasicAuth(user_input[CONF_USERNAME], user_input[CONF_PASSWORD]), + ) + device = await api.get() + except EcoforestAuthenticationRequired: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "cannot_connect" + else: + await self.async_set_unique_id(device.serial_number) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=f"{MANUFACTURER} {device.serial_number}", data=user_input + ) + + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors=errors, + ) diff --git a/homeassistant/components/ecoforest/const.py b/homeassistant/components/ecoforest/const.py new file mode 100644 index 00000000000000..8f8bbdcb45a46a --- /dev/null +++ b/homeassistant/components/ecoforest/const.py @@ -0,0 +1,8 @@ +"""Constants for the Ecoforest integration.""" + +from datetime import timedelta + +DOMAIN = "ecoforest" +MANUFACTURER = "Ecoforest" + +POLLING_INTERVAL = timedelta(seconds=30) diff --git a/homeassistant/components/ecoforest/coordinator.py b/homeassistant/components/ecoforest/coordinator.py new file mode 100644 index 00000000000000..b44ccc850ce423 --- /dev/null +++ b/homeassistant/components/ecoforest/coordinator.py @@ -0,0 +1,39 @@ +"""The ecoforest coordinator.""" + + +import logging + +from pyecoforest.api import EcoforestApi +from pyecoforest.exceptions import EcoforestError +from pyecoforest.models.device import Device + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import POLLING_INTERVAL + +_LOGGER = logging.getLogger(__name__) + + +class EcoforestCoordinator(DataUpdateCoordinator[Device]): + """DataUpdateCoordinator to gather data from ecoforest device.""" + + def __init__(self, hass: HomeAssistant, api: EcoforestApi) -> None: + """Initialize DataUpdateCoordinator.""" + + super().__init__( + hass, + _LOGGER, + name="ecoforest", + update_interval=POLLING_INTERVAL, + ) + self.api = api + + async def _async_update_data(self) -> Device: + """Fetch all device and sensor data from api.""" + try: + data = await self.api.get() + _LOGGER.debug("Ecoforest data: %s", data) + return data + except EcoforestError as err: + raise UpdateFailed(f"Error communicating with API: {err}") from err diff --git a/homeassistant/components/ecoforest/entity.py b/homeassistant/components/ecoforest/entity.py new file mode 100644 index 00000000000000..901ed1bf4bf7b7 --- /dev/null +++ b/homeassistant/components/ecoforest/entity.py @@ -0,0 +1,42 @@ +"""Base Entity for Ecoforest.""" +from __future__ import annotations + +from pyecoforest.models.device import Device + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, MANUFACTURER +from .coordinator import EcoforestCoordinator + + +class EcoforestEntity(CoordinatorEntity[EcoforestCoordinator]): + """Common Ecoforest entity using CoordinatorEntity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: EcoforestCoordinator, + description: EntityDescription, + ) -> None: + """Initialize device information.""" + self.entity_description = description + self._attr_unique_id = f"{coordinator.data.serial_number}_{description.key}" + + super().__init__(coordinator) + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.data.serial_number)}, + name=MANUFACTURER, + model=coordinator.data.model_name, + sw_version=coordinator.data.firmware, + manufacturer=MANUFACTURER, + ) + + @property + def data(self) -> Device: + """Return ecoforest data.""" + assert self.coordinator.data + return self.coordinator.data diff --git a/homeassistant/components/ecoforest/manifest.json b/homeassistant/components/ecoforest/manifest.json new file mode 100644 index 00000000000000..518f4d97a04eb4 --- /dev/null +++ b/homeassistant/components/ecoforest/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "ecoforest", + "name": "Ecoforest", + "codeowners": ["@pjanuario"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/ecoforest", + "iot_class": "local_polling", + "requirements": ["pyecoforest==0.3.0"] +} diff --git a/homeassistant/components/ecoforest/sensor.py b/homeassistant/components/ecoforest/sensor.py new file mode 100644 index 00000000000000..bba0a36037541b --- /dev/null +++ b/homeassistant/components/ecoforest/sensor.py @@ -0,0 +1,72 @@ +"""Support for Ecoforest sensors.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +import logging + +from pyecoforest.models.device import Device + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import EcoforestCoordinator +from .entity import EcoforestEntity + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class EcoforestRequiredKeysMixin: + """Mixin for required keys.""" + + value_fn: Callable[[Device], float | None] + + +@dataclass +class EcoforestSensorEntityDescription( + SensorEntityDescription, EcoforestRequiredKeysMixin +): + """Describes Ecoforest sensor entity.""" + + +SENSOR_TYPES: tuple[EcoforestSensorEntityDescription, ...] = ( + EcoforestSensorEntityDescription( + key="temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + value_fn=lambda data: data.environment_temperature, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Ecoforest sensor platform.""" + coordinator: EcoforestCoordinator = hass.data[DOMAIN][entry.entry_id] + + entities = [ + EcoforestSensor(coordinator, description) for description in SENSOR_TYPES + ] + + async_add_entities(entities) + + +class EcoforestSensor(SensorEntity, EcoforestEntity): + """Representation of an Ecoforest sensor.""" + + entity_description: EcoforestSensorEntityDescription + + @property + def native_value(self) -> float | None: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.data) diff --git a/homeassistant/components/ecoforest/strings.json b/homeassistant/components/ecoforest/strings.json new file mode 100644 index 00000000000000..d6e3212b4eab64 --- /dev/null +++ b/homeassistant/components/ecoforest/strings.json @@ -0,0 +1,21 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/components/econet/__init__.py b/homeassistant/components/econet/__init__.py index 3005993bf99e7c..36cdeb688218a1 100644 --- a/homeassistant/components/econet/__init__.py +++ b/homeassistant/components/econet/__init__.py @@ -13,7 +13,7 @@ ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform, UnitOfTemperature +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.device_registry import DeviceInfo @@ -137,8 +137,3 @@ def device_info(self) -> DeviceInfo: manufacturer="Rheem", name=self._econet.device_name, ) - - @property - def temperature_unit(self): - """Return the unit of measurement.""" - return UnitOfTemperature.FAHRENHEIT diff --git a/homeassistant/components/econet/climate.py b/homeassistant/components/econet/climate.py index 7233d135f2e1cd..e77c4face7477a 100644 --- a/homeassistant/components/econet/climate.py +++ b/homeassistant/components/econet/climate.py @@ -16,7 +16,7 @@ HVACMode, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_TEMPERATURE +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -62,23 +62,21 @@ async def async_setup_entry( class EcoNetThermostat(EcoNetEntity, ClimateEntity): - """Define a Econet thermostat.""" + """Define an Econet thermostat.""" + + _attr_temperature_unit = UnitOfTemperature.FAHRENHEIT def __init__(self, thermostat): """Initialize.""" super().__init__(thermostat) - self._running = thermostat.running - self._poll = True - self.econet_state_to_ha = {} - self.ha_state_to_econet = {} - self.op_list = [] + self._attr_hvac_modes = [] for mode in self._econet.modes: if mode not in [ ThermostatOperationMode.UNKNOWN, ThermostatOperationMode.EMERGENCY_HEAT, ]: ha_mode = ECONET_STATE_TO_HA[mode] - self.op_list.append(ha_mode) + self._attr_hvac_modes.append(ha_mode) @property def supported_features(self) -> ClimateEntityFeature: @@ -142,14 +140,6 @@ def is_aux_heat(self): """Return true if aux heater.""" return self._econet.mode == ThermostatOperationMode.EMERGENCY_HEAT - @property - def hvac_modes(self): - """Return hvac operation ie. heat, cool mode. - - Needs to be one of HVAC_MODE_*. - """ - return self.op_list - @property def hvac_mode(self) -> HVACMode: """Return hvac operation ie. heat, cool, mode. diff --git a/homeassistant/components/econet/water_heater.py b/homeassistant/components/econet/water_heater.py index c94afd8b5d77f2..cbaf4551d03161 100644 --- a/homeassistant/components/econet/water_heater.py +++ b/homeassistant/components/econet/water_heater.py @@ -16,7 +16,7 @@ WaterHeaterEntityFeature, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_TEMPERATURE, STATE_OFF +from homeassistant.const import ATTR_TEMPERATURE, STATE_OFF, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -56,20 +56,20 @@ async def async_setup_entry( class EcoNetWaterHeater(EcoNetEntity, WaterHeaterEntity): - """Define a Econet water heater.""" + """Define an Econet water heater.""" + + _attr_should_poll = True # Override False default from EcoNetEntity + _attr_temperature_unit = UnitOfTemperature.FAHRENHEIT def __init__(self, water_heater): """Initialize.""" super().__init__(water_heater) self._running = water_heater.running - self._attr_should_poll = True # Override False default from EcoNetEntity self.water_heater = water_heater - self.econet_state_to_ha = {} - self.ha_state_to_econet = {} @callback def on_update_received(self): - """Update was pushed from the ecoent API.""" + """Update was pushed from the econet API.""" if self._running != self.water_heater.running: # Water heater running state has changed so check usage on next update self._attr_should_poll = True diff --git a/homeassistant/components/electric_kiwi/coordinator.py b/homeassistant/components/electric_kiwi/coordinator.py index 49611f9febd1f7..b084f4656d50bc 100644 --- a/homeassistant/components/electric_kiwi/coordinator.py +++ b/homeassistant/components/electric_kiwi/coordinator.py @@ -14,7 +14,7 @@ _LOGGER = logging.getLogger(__name__) -HOP_SCAN_INTERVAL = timedelta(hours=2) +HOP_SCAN_INTERVAL = timedelta(minutes=20) class ElectricKiwiHOPDataCoordinator(DataUpdateCoordinator[Hop]): diff --git a/homeassistant/components/electric_kiwi/select.py b/homeassistant/components/electric_kiwi/select.py index 9d883c72d1ee56..eb8aaac8c2faba 100644 --- a/homeassistant/components/electric_kiwi/select.py +++ b/homeassistant/components/electric_kiwi/select.py @@ -50,7 +50,10 @@ def __init__( ) -> None: """Initialise the HOP selection entity.""" super().__init__(coordinator) - self._attr_unique_id = f"{coordinator._ek_api.customer_number}_{coordinator._ek_api.connection_id}_{description.key}" + self._attr_unique_id = ( + f"{coordinator._ek_api.customer_number}" + f"_{coordinator._ek_api.connection_id}_{description.key}" + ) self.entity_description = description self.values_dict = coordinator.get_hop_options() self._attr_options = list(self.values_dict) @@ -58,7 +61,10 @@ def __init__( @property def current_option(self) -> str | None: """Return the currently selected option.""" - return f"{self.coordinator.data.start.start_time} - {self.coordinator.data.end.end_time}" + return ( + f"{self.coordinator.data.start.start_time}" + f" - {self.coordinator.data.end.end_time}" + ) async def async_select_option(self, option: str) -> None: """Change the selected option.""" diff --git a/homeassistant/components/electric_kiwi/sensor.py b/homeassistant/components/electric_kiwi/sensor.py index 8c983b92dd5f14..8017bbf006e055 100644 --- a/homeassistant/components/electric_kiwi/sensor.py +++ b/homeassistant/components/electric_kiwi/sensor.py @@ -62,7 +62,7 @@ def _check_and_move_time(hop: Hop, time: str) -> datetime: return date_time -HOP_SENSOR_TYPE: tuple[ElectricKiwiHOPSensorEntityDescription, ...] = ( +HOP_SENSOR_TYPES: tuple[ElectricKiwiHOPSensorEntityDescription, ...] = ( ElectricKiwiHOPSensorEntityDescription( key=ATTR_EK_HOP_START, translation_key="hopfreepowerstart", @@ -85,7 +85,7 @@ async def async_setup_entry( hop_coordinator: ElectricKiwiHOPDataCoordinator = hass.data[DOMAIN][entry.entry_id] hop_entities = [ ElectricKiwiHOPEntity(hop_coordinator, description) - for description in HOP_SENSOR_TYPE + for description in HOP_SENSOR_TYPES ] async_add_entities(hop_entities) @@ -107,7 +107,10 @@ def __init__( """Entity object for Electric Kiwi sensor.""" super().__init__(coordinator) - self._attr_unique_id = f"{coordinator._ek_api.customer_number}_{coordinator._ek_api.connection_id}_{description.key}" + self._attr_unique_id = ( + f"{coordinator._ek_api.customer_number}" + f"_{coordinator._ek_api.connection_id}_{description.key}" + ) self.entity_description = description @property diff --git a/homeassistant/components/elkm1/__init__.py b/homeassistant/components/elkm1/__init__.py index 352c841910643d..b78157588e82e2 100644 --- a/homeassistant/components/elkm1/__init__.py +++ b/homeassistant/components/elkm1/__init__.py @@ -2,11 +2,12 @@ from __future__ import annotations import asyncio +from collections.abc import Iterable from enum import Enum import logging import re from types import MappingProxyType -from typing import Any, cast +from typing import Any from elkm1_lib.elements import Element from elkm1_lib.elk import Elk @@ -65,6 +66,7 @@ async_trigger_discovery, async_update_entry_from_discovery, ) +from .models import ELKM1Data SYNC_TIMEOUT = 120 @@ -303,14 +305,16 @@ def _keypad_changed(keypad: Element, changeset: dict[str, Any]) -> None: else: temperature_unit = UnitOfTemperature.FAHRENHEIT config["temperature_unit"] = temperature_unit - hass.data[DOMAIN][entry.entry_id] = { - "elk": elk, - "prefix": conf[CONF_PREFIX], - "mac": entry.unique_id, - "auto_configure": conf[CONF_AUTO_CONFIGURE], - "config": config, - "keypads": {}, - } + prefix: str = conf[CONF_PREFIX] + auto_configure: bool = conf[CONF_AUTO_CONFIGURE] + hass.data[DOMAIN][entry.entry_id] = ELKM1Data( + elk=elk, + prefix=prefix, + mac=entry.unique_id, + auto_configure=auto_configure, + config=config, + keypads={}, + ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -326,21 +330,23 @@ def _included(ranges: list[tuple[int, int]], set_to: bool, values: list[bool]) - def _find_elk_by_prefix(hass: HomeAssistant, prefix: str) -> Elk | None: """Search all config entries for a given prefix.""" - for entry_id in hass.data[DOMAIN]: - if hass.data[DOMAIN][entry_id]["prefix"] == prefix: - return cast(Elk, hass.data[DOMAIN][entry_id]["elk"]) + all_elk: dict[str, ELKM1Data] = hass.data[DOMAIN] + for elk_data in all_elk.values(): + if elk_data.prefix == prefix: + return elk_data.elk return None async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + all_elk: dict[str, ELKM1Data] = hass.data[DOMAIN] # disconnect cleanly - hass.data[DOMAIN][entry.entry_id]["elk"].disconnect() + all_elk[entry.entry_id].elk.disconnect() if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) + all_elk.pop(entry.entry_id) return unload_ok @@ -421,19 +427,19 @@ def _set_time_service(service: ServiceCall) -> None: def create_elk_entities( - elk_data: dict[str, Any], - elk_elements: list[Element], + elk_data: ELKM1Data, + elk_elements: Iterable[Element], element_type: str, class_: Any, entities: list[ElkEntity], ) -> list[ElkEntity] | None: """Create the ElkM1 devices of a particular class.""" - auto_configure = elk_data["auto_configure"] + auto_configure = elk_data.auto_configure - if not auto_configure and not elk_data["config"][element_type]["enabled"]: + if not auto_configure and not elk_data.config[element_type]["enabled"]: return None - elk = elk_data["elk"] + elk = elk_data.elk _LOGGER.debug("Creating elk entities for %s", elk) for element in elk_elements: @@ -441,7 +447,7 @@ def create_elk_entities( if not element.configured: continue # Only check the included list if auto configure is not - elif not elk_data["config"][element_type]["included"][element.index]: + elif not elk_data.config[element_type]["included"][element.index]: continue entities.append(class_(element, elk, elk_data)) @@ -454,13 +460,13 @@ class ElkEntity(Entity): _attr_has_entity_name = True _attr_should_poll = False - def __init__(self, element: Element, elk: Elk, elk_data: dict[str, Any]) -> None: + def __init__(self, element: Element, elk: Elk, elk_data: ELKM1Data) -> None: """Initialize the base of all Elk devices.""" self._elk = elk self._element = element - self._mac = elk_data["mac"] - self._prefix = elk_data["prefix"] - self._temperature_unit: str = elk_data["config"]["temperature_unit"] + self._mac = elk_data.mac + self._prefix = elk_data.prefix + self._temperature_unit: str = elk_data.config["temperature_unit"] # unique_id starts with elkm1_ iff there is no prefix # it starts with elkm1m_{prefix} iff there is a prefix # this is to avoid a conflict between @@ -496,9 +502,7 @@ def available(self) -> bool: def initial_attrs(self) -> dict[str, Any]: """Return the underlying element's attributes as a dict.""" - attrs = {} - attrs["index"] = self._element.index + 1 - return attrs + return {"index": self._element.index + 1} def _element_changed(self, element: Element, changeset: dict[str, Any]) -> None: pass @@ -518,6 +522,8 @@ async def async_added_to_hass(self) -> None: def device_info(self) -> DeviceInfo: """Device info connecting via the ElkM1 system.""" return DeviceInfo( + name=self._element.name, + identifiers={(DOMAIN, self._unique_id)}, via_device=(DOMAIN, f"{self._prefix}_system"), ) diff --git a/homeassistant/components/elkm1/alarm_control_panel.py b/homeassistant/components/elkm1/alarm_control_panel.py index 3f5163a849dce4..bfac466caeb921 100644 --- a/homeassistant/components/elkm1/alarm_control_panel.py +++ b/homeassistant/components/elkm1/alarm_control_panel.py @@ -40,6 +40,7 @@ DOMAIN, ELK_USER_CODE_SERVICE_SCHEMA, ) +from .models import ELKM1Data DISPLAY_MESSAGE_SERVICE_SCHEMA = { vol.Optional("clear", default=2): vol.All(vol.Coerce(int), vol.In([0, 1, 2])), @@ -65,8 +66,9 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the ElkM1 alarm platform.""" - elk_data = hass.data[DOMAIN][config_entry.entry_id] - elk = elk_data["elk"] + + elk_data: ELKM1Data = hass.data[DOMAIN][config_entry.entry_id] + elk = elk_data.elk entities: list[ElkEntity] = [] create_elk_entities(elk_data, elk.areas, "area", ElkArea, entities) async_add_entities(entities) @@ -115,7 +117,7 @@ class ElkArea(ElkAttachedEntity, AlarmControlPanelEntity, RestoreEntity): ) _element: Area - def __init__(self, element: Element, elk: Elk, elk_data: dict[str, Any]) -> None: + def __init__(self, element: Element, elk: Elk, elk_data: ELKM1Data) -> None: """Initialize Area as Alarm Control Panel.""" super().__init__(element, elk, elk_data) self._elk = elk diff --git a/homeassistant/components/elkm1/binary_sensor.py b/homeassistant/components/elkm1/binary_sensor.py index 38a72796482934..95f9162468e3d7 100644 --- a/homeassistant/components/elkm1/binary_sensor.py +++ b/homeassistant/components/elkm1/binary_sensor.py @@ -14,6 +14,7 @@ from . import ElkAttachedEntity, ElkEntity from .const import DOMAIN +from .models import ELKM1Data async def async_setup_entry( @@ -22,21 +23,20 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Create the Elk-M1 sensor platform.""" - - elk_data = hass.data[DOMAIN][config_entry.entry_id] - auto_configure = elk_data["auto_configure"] - elk = elk_data["elk"] + elk_data: ELKM1Data = hass.data[DOMAIN][config_entry.entry_id] + elk = elk_data.elk + auto_configure = elk_data.auto_configure entities: list[ElkEntity] = [] for element in elk.zones: # Don't create binary sensors for zones that are analog - if element.definition in {ZoneType.TEMPERATURE, ZoneType.ANALOG_ZONE}: + if element.definition in {ZoneType.TEMPERATURE, ZoneType.ANALOG_ZONE}: # type: ignore[attr-defined] continue if auto_configure: if not element.configured: continue - elif not elk_data["config"]["zone"]["included"][element.index]: + elif not elk_data.config["zone"]["included"][element.index]: continue entities.append(ElkBinarySensor(element, elk, elk_data)) diff --git a/homeassistant/components/elkm1/climate.py b/homeassistant/components/elkm1/climate.py index 1ece7a7758a0b8..c1e6dc7b034df9 100644 --- a/homeassistant/components/elkm1/climate.py +++ b/homeassistant/components/elkm1/climate.py @@ -23,6 +23,7 @@ from . import ElkEntity, create_elk_entities from .const import DOMAIN +from .models import ELKM1Data SUPPORT_HVAC = [ HVACMode.OFF, @@ -61,9 +62,9 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Create the Elk-M1 thermostat platform.""" - elk_data = hass.data[DOMAIN][config_entry.entry_id] + elk_data: ELKM1Data = hass.data[DOMAIN][config_entry.entry_id] + elk = elk_data.elk entities: list[ElkEntity] = [] - elk = elk_data["elk"] create_elk_entities( elk_data, elk.thermostats, "thermostat", ElkThermostat, entities ) diff --git a/homeassistant/components/elkm1/light.py b/homeassistant/components/elkm1/light.py index 3db457761aacd6..844e4f3dd15569 100644 --- a/homeassistant/components/elkm1/light.py +++ b/homeassistant/components/elkm1/light.py @@ -14,6 +14,7 @@ from . import ElkEntity, create_elk_entities from .const import DOMAIN +from .models import ELKM1Data async def async_setup_entry( @@ -22,9 +23,9 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Elk light platform.""" - elk_data = hass.data[DOMAIN][config_entry.entry_id] + elk_data: ELKM1Data = hass.data[DOMAIN][config_entry.entry_id] + elk = elk_data.elk entities: list[ElkEntity] = [] - elk = elk_data["elk"] create_elk_entities(elk_data, elk.lights, "plc", ElkLight, entities) async_add_entities(entities) @@ -36,7 +37,7 @@ class ElkLight(ElkEntity, LightEntity): _attr_supported_color_modes = {ColorMode.BRIGHTNESS} _element: Light - def __init__(self, element: Element, elk: Elk, elk_data: dict[str, Any]) -> None: + def __init__(self, element: Element, elk: Elk, elk_data: ELKM1Data) -> None: """Initialize the Elk light.""" super().__init__(element, elk, elk_data) self._brightness = self._element.status diff --git a/homeassistant/components/elkm1/manifest.json b/homeassistant/components/elkm1/manifest.json index ccac1593fa002c..3ec5be46d41cfd 100644 --- a/homeassistant/components/elkm1/manifest.json +++ b/homeassistant/components/elkm1/manifest.json @@ -15,5 +15,5 @@ "documentation": "https://www.home-assistant.io/integrations/elkm1", "iot_class": "local_push", "loggers": ["elkm1_lib"], - "requirements": ["elkm1-lib==2.2.5"] + "requirements": ["elkm1-lib==2.2.6"] } diff --git a/homeassistant/components/elkm1/models.py b/homeassistant/components/elkm1/models.py new file mode 100644 index 00000000000000..9f784951c11c87 --- /dev/null +++ b/homeassistant/components/elkm1/models.py @@ -0,0 +1,19 @@ +"""The elkm1 integration models.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from elkm1_lib import Elk + + +@dataclass(slots=True) +class ELKM1Data: + """Data for the elkm1 integration.""" + + elk: Elk + prefix: str + mac: str | None + auto_configure: bool + config: dict[str, Any] + keypads: dict[str, Any] diff --git a/homeassistant/components/elkm1/scene.py b/homeassistant/components/elkm1/scene.py index 1869e5ba0f3446..9cb0c62ff77434 100644 --- a/homeassistant/components/elkm1/scene.py +++ b/homeassistant/components/elkm1/scene.py @@ -12,6 +12,7 @@ from . import ElkAttachedEntity, ElkEntity, create_elk_entities from .const import DOMAIN +from .models import ELKM1Data async def async_setup_entry( @@ -20,9 +21,9 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Create the Elk-M1 scene platform.""" - elk_data = hass.data[DOMAIN][config_entry.entry_id] + elk_data: ELKM1Data = hass.data[DOMAIN][config_entry.entry_id] + elk = elk_data.elk entities: list[ElkEntity] = [] - elk = elk_data["elk"] create_elk_entities(elk_data, elk.tasks, "task", ElkTask, entities) async_add_entities(entities) diff --git a/homeassistant/components/elkm1/sensor.py b/homeassistant/components/elkm1/sensor.py index 0de97a1710e516..9bd78f6167343d 100644 --- a/homeassistant/components/elkm1/sensor.py +++ b/homeassistant/components/elkm1/sensor.py @@ -23,6 +23,7 @@ from . import ElkAttachedEntity, ElkEntity, create_elk_entities from .const import ATTR_VALUE, DOMAIN, ELK_USER_CODE_SERVICE_SCHEMA +from .models import ELKM1Data SERVICE_SENSOR_COUNTER_REFRESH = "sensor_counter_refresh" SERVICE_SENSOR_COUNTER_SET = "sensor_counter_set" @@ -41,9 +42,9 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Create the Elk-M1 sensor platform.""" - elk_data = hass.data[DOMAIN][config_entry.entry_id] + elk_data: ELKM1Data = hass.data[DOMAIN][config_entry.entry_id] + elk = elk_data.elk entities: list[ElkEntity] = [] - elk = elk_data["elk"] create_elk_entities(elk_data, elk.counters, "counter", ElkCounter, entities) create_elk_entities(elk_data, elk.keypads, "keypad", ElkKeypad, entities) create_elk_entities(elk_data, [elk.panel], "panel", ElkPanel, entities) diff --git a/homeassistant/components/elkm1/switch.py b/homeassistant/components/elkm1/switch.py index a17557b15077e7..b4080adc6987fe 100644 --- a/homeassistant/components/elkm1/switch.py +++ b/homeassistant/components/elkm1/switch.py @@ -12,6 +12,7 @@ from . import ElkAttachedEntity, ElkEntity, create_elk_entities from .const import DOMAIN +from .models import ELKM1Data async def async_setup_entry( @@ -20,9 +21,9 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Create the Elk-M1 switch platform.""" - elk_data = hass.data[DOMAIN][config_entry.entry_id] + elk_data: ELKM1Data = hass.data[DOMAIN][config_entry.entry_id] + elk = elk_data.elk entities: list[ElkEntity] = [] - elk = elk_data["elk"] create_elk_entities(elk_data, elk.outputs, "output", ElkOutput, entities) async_add_entities(entities) diff --git a/homeassistant/components/emulated_hue/manifest.json b/homeassistant/components/emulated_hue/manifest.json index 01dae2dca77275..ff3591e00667d5 100644 --- a/homeassistant/components/emulated_hue/manifest.json +++ b/homeassistant/components/emulated_hue/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/emulated_hue", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["aiohttp-cors==0.7.0"] + "requirements": ["aiohttp_cors==0.7.0"] } diff --git a/homeassistant/components/emulated_kasa/manifest.json b/homeassistant/components/emulated_kasa/manifest.json index 324279db7d9e66..843aeddde7bcde 100644 --- a/homeassistant/components/emulated_kasa/manifest.json +++ b/homeassistant/components/emulated_kasa/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_push", "loggers": ["sense_energy"], "quality_scale": "internal", - "requirements": ["sense_energy==0.12.0"] + "requirements": ["sense-energy==0.12.2"] } diff --git a/homeassistant/components/energy/sensor.py b/homeassistant/components/energy/sensor.py index ae92ee2de58997..e9760a96aa4e1f 100644 --- a/homeassistant/components/energy/sensor.py +++ b/homeassistant/components/energy/sensor.py @@ -377,11 +377,10 @@ def _update_cost(self) -> None: if energy_price_unit is None: converted_energy_price = energy_price else: - if self._adapter.source_type == "grid": - converter: Callable[ - [float, str, str], float - ] = unit_conversion.EnergyConverter.convert - elif self._adapter.source_type in ("gas", "water"): + converter: Callable[[float, str, str], float] + if energy_unit in VALID_ENERGY_UNITS: + converter = unit_conversion.EnergyConverter.convert + else: converter = unit_conversion.VolumeConverter.convert converted_energy_price = converter( diff --git a/homeassistant/components/enmax/__init__.py b/homeassistant/components/enmax/__init__.py new file mode 100644 index 00000000000000..21ca8ab1c58afb --- /dev/null +++ b/homeassistant/components/enmax/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Enmax Energy.""" diff --git a/homeassistant/components/enmax/manifest.json b/homeassistant/components/enmax/manifest.json new file mode 100644 index 00000000000000..2c2be41382462a --- /dev/null +++ b/homeassistant/components/enmax/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "enmax", + "name": "Enmax Energy", + "integration_type": "virtual", + "supported_by": "opower" +} diff --git a/homeassistant/components/enphase_envoy/config_flow.py b/homeassistant/components/enphase_envoy/config_flow.py index b41d29626e70ab..999542ee2a5e66 100644 --- a/homeassistant/components/enphase_envoy/config_flow.py +++ b/homeassistant/components/enphase_envoy/config_flow.py @@ -15,7 +15,6 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.httpx_client import get_async_client -from homeassistant.util.network import is_ipv4_address from .const import DOMAIN, INVALID_AUTH_ERRORS @@ -90,7 +89,7 @@ async def async_step_zeroconf( self, discovery_info: zeroconf.ZeroconfServiceInfo ) -> FlowResult: """Handle a flow initialized by zeroconf discovery.""" - if not is_ipv4_address(discovery_info.host): + if discovery_info.ip_address.version != 4: return self.async_abort(reason="not_ipv4_address") serial = discovery_info.properties["serialnum"] self.protovers = discovery_info.properties.get("protovers") diff --git a/homeassistant/components/enphase_envoy/manifest.json b/homeassistant/components/enphase_envoy/manifest.json index 540c121bb17025..917e325be5134c 100644 --- a/homeassistant/components/enphase_envoy/manifest.json +++ b/homeassistant/components/enphase_envoy/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/enphase_envoy", "iot_class": "local_polling", "loggers": ["pyenphase"], - "requirements": ["pyenphase==1.8.1"], + "requirements": ["pyenphase==1.11.4"], "zeroconf": [ { "type": "_enphase-envoy._tcp.local." diff --git a/homeassistant/components/enphase_envoy/strings.json b/homeassistant/components/enphase_envoy/strings.json index ae0ac31413caad..92eca38ef20288 100644 --- a/homeassistant/components/enphase_envoy/strings.json +++ b/homeassistant/components/enphase_envoy/strings.json @@ -3,7 +3,7 @@ "flow_title": "{serial} ({host})", "step": { "user": { - "description": "For firmware version 7.0 and later, enter the Enphase cloud credentials, for older models models, enter username `installer` without a password.", + "description": "For firmware version 7.0 and later, enter the Enphase cloud credentials, for older models, enter username `installer` without a password.", "data": { "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", diff --git a/homeassistant/components/envisalink/__init__.py b/homeassistant/components/envisalink/__init__.py index 55ad58a030d0be..b0a4619bbf9e5c 100644 --- a/homeassistant/components/envisalink/__init__.py +++ b/homeassistant/components/envisalink/__init__.py @@ -124,7 +124,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: zones = conf.get(CONF_ZONES) partitions = conf.get(CONF_PARTITIONS) connection_timeout = conf.get(CONF_TIMEOUT) - sync_connect: asyncio.Future[bool] = asyncio.Future() + sync_connect: asyncio.Future[bool] = hass.loop.create_future() controller = EnvisalinkAlarmPanel( host, diff --git a/homeassistant/components/epson/manifest.json b/homeassistant/components/epson/manifest.json index 77a1a89b68660f..7b8f8d8a4a2b20 100644 --- a/homeassistant/components/epson/manifest.json +++ b/homeassistant/components/epson/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/epson", "iot_class": "local_polling", "loggers": ["epson_projector"], - "requirements": ["epson-projector==0.5.0"] + "requirements": ["epson-projector==0.5.1"] } diff --git a/homeassistant/components/epson/media_player.py b/homeassistant/components/epson/media_player.py index 5c49f566bb590d..1f80be9fe06a09 100644 --- a/homeassistant/components/epson/media_player.py +++ b/homeassistant/components/epson/media_player.py @@ -6,7 +6,7 @@ from epson_projector import Projector, ProjectorUnavailableError from epson_projector.const import ( BACK, - BUSY, + BUSY_CODES, CMODE, CMODE_LIST, CMODE_LIST_SET, @@ -147,7 +147,7 @@ async def async_update(self) -> None: self._attr_volume_level = float(volume) except ValueError: self._attr_volume_level = None - elif power_state == BUSY: + elif power_state in BUSY_CODES: self._attr_state = MediaPlayerState.ON else: self._attr_state = MediaPlayerState.OFF diff --git a/homeassistant/components/esphome/bluetooth/client.py b/homeassistant/components/esphome/bluetooth/client.py index ad43ca5df7d016..411a5b989a3a6e 100644 --- a/homeassistant/components/esphome/bluetooth/client.py +++ b/homeassistant/components/esphome/bluetooth/client.py @@ -7,9 +7,15 @@ from dataclasses import dataclass, field from functools import partial import logging +import sys from typing import Any, TypeVar, cast import uuid +if sys.version_info < (3, 12): + from typing_extensions import Buffer +else: + from collections.abc import Buffer + from aioesphomeapi import ( ESP_CONNECTION_ERROR_DESCRIPTION, ESPHOME_GATT_ERRORS, @@ -620,14 +626,14 @@ async def read_gatt_descriptor(self, handle: int, **kwargs: Any) -> bytearray: @api_error_as_bleak_error async def write_gatt_char( self, - char_specifier: BleakGATTCharacteristic | int | str | uuid.UUID, - data: bytes | bytearray | memoryview, + characteristic: BleakGATTCharacteristic | int | str | uuid.UUID, + data: Buffer, response: bool = False, ) -> None: """Perform a write operation of the specified GATT characteristic. Args: - char_specifier (BleakGATTCharacteristic, int, str or UUID): + characteristic (BleakGATTCharacteristic, int, str or UUID): The characteristic to write to, specified by either integer handle, UUID or directly by the BleakGATTCharacteristic object representing it. @@ -635,16 +641,14 @@ async def write_gatt_char( response (bool): If write-with-response operation should be done. Defaults to `False`. """ - characteristic = self._resolve_characteristic(char_specifier) + characteristic = self._resolve_characteristic(characteristic) await self._client.bluetooth_gatt_write( self._address_as_int, characteristic.handle, bytes(data), response ) @verify_connected @api_error_as_bleak_error - async def write_gatt_descriptor( - self, handle: int, data: bytes | bytearray | memoryview - ) -> None: + async def write_gatt_descriptor(self, handle: int, data: Buffer) -> None: """Perform a write operation on the specified GATT descriptor. Args: diff --git a/homeassistant/components/esphome/bluetooth/device.py b/homeassistant/components/esphome/bluetooth/device.py index 8d060151dbf254..c76562a2145a3d 100644 --- a/homeassistant/components/esphome/bluetooth/device.py +++ b/homeassistant/components/esphome/bluetooth/device.py @@ -21,6 +21,7 @@ class ESPHomeBluetoothDevice: _ble_connection_free_futures: list[asyncio.Future[int]] = field( default_factory=list ) + loop: asyncio.AbstractEventLoop = field(default_factory=asyncio.get_running_loop) @callback def async_update_ble_connection_limits(self, free: int, limit: int) -> None: @@ -49,6 +50,6 @@ async def wait_for_ble_connections_free(self) -> int: """Wait until there are free BLE connections.""" if self.ble_connections_free > 0: return self.ble_connections_free - fut: asyncio.Future[int] = asyncio.Future() + fut: asyncio.Future[int] = self.loop.create_future() self._ble_connection_free_futures.append(fut) return await fut diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 5a4220464e77a4..65c5bf44d5b00b 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -15,9 +15,9 @@ "iot_class": "local_push", "loggers": ["aioesphomeapi", "noiseprotocol"], "requirements": [ - "async_interrupt==1.1.1", - "aioesphomeapi==16.0.3", - "bluetooth-data-tools==1.9.1", + "async-interrupt==1.1.1", + "aioesphomeapi==16.0.5", + "bluetooth-data-tools==1.11.0", "esphome-dashboard-api==1.2.3" ], "zeroconf": ["_esphomelib._tcp.local."] diff --git a/homeassistant/components/event/__init__.py b/homeassistant/components/event/__init__.py index f6ba2d79bfecba..d960867097276f 100644 --- a/homeassistant/components/event/__init__.py +++ b/homeassistant/components/event/__init__.py @@ -105,6 +105,8 @@ def from_dict(cls, restored: dict[str, Any]) -> Self | None: class EventEntity(RestoreEntity): """Representation of an Event entity.""" + _entity_component_unrecorded_attributes = frozenset({ATTR_EVENT_TYPES}) + entity_description: EventEntityDescription _attr_device_class: EventDeviceClass | None _attr_event_types: list[str] diff --git a/homeassistant/components/event/recorder.py b/homeassistant/components/event/recorder.py deleted file mode 100644 index 759fd80bcf0e0d..00000000000000 --- a/homeassistant/components/event/recorder.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.core import HomeAssistant, callback - -from . import ATTR_EVENT_TYPES - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude static attributes from being recorded in the database.""" - return {ATTR_EVENT_TYPES} diff --git a/homeassistant/components/faa_delays/__init__.py b/homeassistant/components/faa_delays/__init__.py index b165492d07631c..3606da334990cb 100644 --- a/homeassistant/components/faa_delays/__init__.py +++ b/homeassistant/components/faa_delays/__init__.py @@ -1,20 +1,10 @@ """The FAA Delays integration.""" -import asyncio -from datetime import timedelta -import logging - -from aiohttp import ClientConnectionError -from faadelays import Airport - from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ID, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import aiohttp_client -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN - -_LOGGER = logging.getLogger(__name__) +from .coordinator import FAADataUpdateCoordinator PLATFORMS = [Platform.BINARY_SENSOR] @@ -40,24 +30,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if unload_ok: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -class FAADataUpdateCoordinator(DataUpdateCoordinator): - """Class to manage fetching FAA API data from a single endpoint.""" - - def __init__(self, hass, code): - """Initialize the coordinator.""" - super().__init__( - hass, _LOGGER, name=DOMAIN, update_interval=timedelta(minutes=1) - ) - self.session = aiohttp_client.async_get_clientsession(hass) - self.data = Airport(code, self.session) - self.code = code - - async def _async_update_data(self): - try: - async with asyncio.timeout(10): - await self.data.update() - except ClientConnectionError as err: - raise UpdateFailed(err) from err - return self.data diff --git a/homeassistant/components/faa_delays/coordinator.py b/homeassistant/components/faa_delays/coordinator.py new file mode 100644 index 00000000000000..f2aefdada66c1c --- /dev/null +++ b/homeassistant/components/faa_delays/coordinator.py @@ -0,0 +1,35 @@ +"""DataUpdateCoordinator for faa_delays integration.""" +import asyncio +from datetime import timedelta +import logging + +from aiohttp import ClientConnectionError +from faadelays import Airport + +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class FAADataUpdateCoordinator(DataUpdateCoordinator): + """Class to manage fetching FAA API data from a single endpoint.""" + + def __init__(self, hass, code): + """Initialize the coordinator.""" + super().__init__( + hass, _LOGGER, name=DOMAIN, update_interval=timedelta(minutes=1) + ) + self.session = aiohttp_client.async_get_clientsession(hass) + self.data = Airport(code, self.session) + self.code = code + + async def _async_update_data(self): + try: + async with asyncio.timeout(10): + await self.data.update() + except ClientConnectionError as err: + raise UpdateFailed(err) from err + return self.data diff --git a/homeassistant/components/fan/__init__.py b/homeassistant/components/fan/__init__.py index 6aa29d8b8042f0..a149909e029f2d 100644 --- a/homeassistant/components/fan/__init__.py +++ b/homeassistant/components/fan/__init__.py @@ -183,6 +183,8 @@ class FanEntityDescription(ToggleEntityDescription): class FanEntity(ToggleEntity): """Base class for fan entities.""" + _entity_component_unrecorded_attributes = frozenset({ATTR_PRESET_MODES}) + entity_description: FanEntityDescription _attr_current_direction: str | None = None _attr_oscillating: bool | None = None diff --git a/homeassistant/components/fan/recorder.py b/homeassistant/components/fan/recorder.py deleted file mode 100644 index e7305b64f16a23..00000000000000 --- a/homeassistant/components/fan/recorder.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.core import HomeAssistant, callback - -from . import ATTR_PRESET_MODES - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude static attributes from being recorded in the database.""" - return {ATTR_PRESET_MODES} diff --git a/homeassistant/components/fibaro/__init__.py b/homeassistant/components/fibaro/__init__.py index 86f25253c2da21..ffa13749fa7034 100644 --- a/homeassistant/components/fibaro/__init__.py +++ b/homeassistant/components/fibaro/__init__.py @@ -35,8 +35,6 @@ _LOGGER = logging.getLogger(__name__) -FIBARO_CONTROLLER = "fibaro_controller" -FIBARO_DEVICES = "fibaro_devices" PLATFORMS = [ Platform.BINARY_SENSOR, Platform.CLIMATE, @@ -377,12 +375,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except FibaroAuthFailed as auth_ex: raise ConfigEntryAuthFailed from auth_ex - data: dict[str, Any] = {} - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = data - data[FIBARO_CONTROLLER] = controller - devices = data[FIBARO_DEVICES] = {} - for platform in PLATFORMS: - devices[platform] = [*controller.fibaro_devices[platform]] + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = controller # register the hub device info separately as the hub has sometimes no entities device_registry = dr.async_get(hass) @@ -408,7 +401,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.debug("Shutting down Fibaro connection") unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - hass.data[DOMAIN][entry.entry_id][FIBARO_CONTROLLER].disable_state_handler() + hass.data[DOMAIN][entry.entry_id].disable_state_handler() hass.data[DOMAIN].pop(entry.entry_id) return unload_ok diff --git a/homeassistant/components/fibaro/binary_sensor.py b/homeassistant/components/fibaro/binary_sensor.py index 57b3bc99b4f402..07c0d9a779cd6c 100644 --- a/homeassistant/components/fibaro/binary_sensor.py +++ b/homeassistant/components/fibaro/binary_sensor.py @@ -16,7 +16,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import FIBARO_DEVICES, FibaroDevice +from . import FibaroController, FibaroDevice from .const import DOMAIN SENSOR_TYPES = { @@ -45,12 +45,11 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Perform the setup for Fibaro controller devices.""" + controller: FibaroController = hass.data[DOMAIN][entry.entry_id] async_add_entities( [ FibaroBinarySensor(device) - for device in hass.data[DOMAIN][entry.entry_id][FIBARO_DEVICES][ - Platform.BINARY_SENSOR - ] + for device in controller.fibaro_devices[Platform.BINARY_SENSOR] ], True, ) diff --git a/homeassistant/components/fibaro/climate.py b/homeassistant/components/fibaro/climate.py index a56056ade03727..18fef8dbe7a3f6 100644 --- a/homeassistant/components/fibaro/climate.py +++ b/homeassistant/components/fibaro/climate.py @@ -21,7 +21,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import FIBARO_DEVICES, FibaroDevice +from . import FibaroController, FibaroDevice from .const import DOMAIN PRESET_RESUME = "resume" @@ -113,12 +113,11 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Perform the setup for Fibaro controller devices.""" + controller: FibaroController = hass.data[DOMAIN][entry.entry_id] async_add_entities( [ FibaroThermostat(device) - for device in hass.data[DOMAIN][entry.entry_id][FIBARO_DEVICES][ - Platform.CLIMATE - ] + for device in controller.fibaro_devices[Platform.CLIMATE] ], True, ) diff --git a/homeassistant/components/fibaro/cover.py b/homeassistant/components/fibaro/cover.py index c73c45d254c615..d353b352c5cb89 100644 --- a/homeassistant/components/fibaro/cover.py +++ b/homeassistant/components/fibaro/cover.py @@ -17,7 +17,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import FIBARO_DEVICES, FibaroDevice +from . import FibaroController, FibaroDevice from .const import DOMAIN @@ -27,13 +27,9 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Fibaro covers.""" + controller: FibaroController = hass.data[DOMAIN][entry.entry_id] async_add_entities( - [ - FibaroCover(device) - for device in hass.data[DOMAIN][entry.entry_id][FIBARO_DEVICES][ - Platform.COVER - ] - ], + [FibaroCover(device) for device in controller.fibaro_devices[Platform.COVER]], True, ) diff --git a/homeassistant/components/fibaro/light.py b/homeassistant/components/fibaro/light.py index 6a918f64f86b21..981b81fdd4339e 100644 --- a/homeassistant/components/fibaro/light.py +++ b/homeassistant/components/fibaro/light.py @@ -23,7 +23,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import FIBARO_DEVICES, FibaroDevice +from . import FibaroController, FibaroDevice from .const import DOMAIN PARALLEL_UPDATES = 2 @@ -56,13 +56,9 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Perform the setup for Fibaro controller devices.""" + controller: FibaroController = hass.data[DOMAIN][entry.entry_id] async_add_entities( - [ - FibaroLight(device) - for device in hass.data[DOMAIN][entry.entry_id][FIBARO_DEVICES][ - Platform.LIGHT - ] - ], + [FibaroLight(device) for device in controller.fibaro_devices[Platform.LIGHT]], True, ) diff --git a/homeassistant/components/fibaro/lock.py b/homeassistant/components/fibaro/lock.py index 503407bc28f2bd..715116d2843c46 100644 --- a/homeassistant/components/fibaro/lock.py +++ b/homeassistant/components/fibaro/lock.py @@ -11,7 +11,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import FIBARO_DEVICES, FibaroDevice +from . import FibaroController, FibaroDevice from .const import DOMAIN @@ -21,13 +21,9 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Fibaro locks.""" + controller: FibaroController = hass.data[DOMAIN][entry.entry_id] async_add_entities( - [ - FibaroLock(device) - for device in hass.data[DOMAIN][entry.entry_id][FIBARO_DEVICES][ - Platform.LOCK - ] - ], + [FibaroLock(device) for device in controller.fibaro_devices[Platform.LOCK]], True, ) diff --git a/homeassistant/components/fibaro/scene.py b/homeassistant/components/fibaro/scene.py index 812a85b2f50eb3..36d2666f97dd74 100644 --- a/homeassistant/components/fibaro/scene.py +++ b/homeassistant/components/fibaro/scene.py @@ -13,7 +13,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import slugify -from . import FIBARO_DEVICES, FibaroController +from . import FibaroController from .const import DOMAIN @@ -23,13 +23,9 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Perform the setup for Fibaro scenes.""" + controller: FibaroController = hass.data[DOMAIN][entry.entry_id] async_add_entities( - [ - FibaroScene(scene) - for scene in hass.data[DOMAIN][entry.entry_id][FIBARO_DEVICES][ - Platform.SCENE - ] - ], + [FibaroScene(scene) for scene in controller.fibaro_devices[Platform.SCENE]], True, ) diff --git a/homeassistant/components/fibaro/sensor.py b/homeassistant/components/fibaro/sensor.py index b98e12b889e051..e859a9b1afbac0 100644 --- a/homeassistant/components/fibaro/sensor.py +++ b/homeassistant/components/fibaro/sensor.py @@ -26,7 +26,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import convert -from . import FIBARO_DEVICES, FibaroDevice +from . import FibaroController, FibaroDevice from .const import DOMAIN # List of known sensors which represents a fibaro device @@ -107,7 +107,9 @@ async def async_setup_entry( """Set up the Fibaro controller devices.""" entities: list[SensorEntity] = [] - for device in hass.data[DOMAIN][entry.entry_id][FIBARO_DEVICES][Platform.SENSOR]: + controller: FibaroController = hass.data[DOMAIN][entry.entry_id] + + for device in controller.fibaro_devices[Platform.SENSOR]: entity_description = MAIN_SENSOR_TYPES.get(device.type) # main sensors are created even if the entity type is not known @@ -122,7 +124,7 @@ async def async_setup_entry( Platform.SENSOR, Platform.SWITCH, ): - for device in hass.data[DOMAIN][entry.entry_id][FIBARO_DEVICES][platform]: + for device in controller.fibaro_devices[platform]: for entity_description in ADDITIONAL_SENSOR_TYPES: if entity_description.key in device.properties: entities.append(FibaroAdditionalSensor(device, entity_description)) diff --git a/homeassistant/components/fibaro/switch.py b/homeassistant/components/fibaro/switch.py index 6ca770ab2d1c5b..fdd473ea28259a 100644 --- a/homeassistant/components/fibaro/switch.py +++ b/homeassistant/components/fibaro/switch.py @@ -11,7 +11,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import FIBARO_DEVICES, FibaroDevice +from . import FibaroController, FibaroDevice from .const import DOMAIN @@ -21,13 +21,9 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Fibaro switches.""" + controller: FibaroController = hass.data[DOMAIN][entry.entry_id] async_add_entities( - [ - FibaroSwitch(device) - for device in hass.data[DOMAIN][entry.entry_id][FIBARO_DEVICES][ - Platform.SWITCH - ] - ], + [FibaroSwitch(device) for device in controller.fibaro_devices[Platform.SWITCH]], True, ) diff --git a/homeassistant/components/filesize/__init__.py b/homeassistant/components/filesize/__init__.py index 73f060e79b7044..9d7cc99421f59a 100644 --- a/homeassistant/components/filesize/__init__.py +++ b/homeassistant/components/filesize/__init__.py @@ -9,10 +9,11 @@ from homeassistant.exceptions import ConfigEntryNotReady from .const import PLATFORMS +from .coordinator import FileSizeCoordinator -def _check_path(hass: HomeAssistant, path: str) -> None: - """Check if path is valid and allowed.""" +def _get_full_path(hass: HomeAssistant, path: str) -> str: + """Check if path is valid, allowed and return full path.""" get_path = pathlib.Path(path) if not get_path.exists() or not get_path.is_file(): raise ConfigEntryNotReady(f"Can not access file {path}") @@ -20,10 +21,17 @@ def _check_path(hass: HomeAssistant, path: str) -> None: if not hass.config.is_allowed_path(path): raise ConfigEntryNotReady(f"Filepath {path} is not valid or allowed") + return str(get_path.absolute()) + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up from a config entry.""" - await hass.async_add_executor_job(_check_path, hass, entry.data[CONF_FILE_PATH]) + full_path = await hass.async_add_executor_job( + _get_full_path, hass, entry.data[CONF_FILE_PATH] + ) + coordinator = FileSizeCoordinator(hass, full_path) + await coordinator.async_config_entry_first_refresh() + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/filesize/coordinator.py b/homeassistant/components/filesize/coordinator.py new file mode 100644 index 00000000000000..75411f8497557f --- /dev/null +++ b/homeassistant/components/filesize/coordinator.py @@ -0,0 +1,48 @@ +"""Coordinator for monitoring the size of a file.""" +from __future__ import annotations + +from datetime import datetime, timedelta +import logging +import os + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +import homeassistant.util.dt as dt_util + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class FileSizeCoordinator(DataUpdateCoordinator[dict[str, int | float | datetime]]): + """Filesize coordinator.""" + + def __init__(self, hass: HomeAssistant, path: str) -> None: + """Initialize filesize coordinator.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=60), + always_update=False, + ) + self._path = path + + async def _async_update_data(self) -> dict[str, float | int | datetime]: + """Fetch file information.""" + try: + statinfo = await self.hass.async_add_executor_job(os.stat, self._path) + except OSError as error: + raise UpdateFailed(f"Can not retrieve file statistics {error}") from error + + size = statinfo.st_size + last_updated = dt_util.utc_from_timestamp(statinfo.st_mtime) + + _LOGGER.debug("size %s, last updated %s", size, last_updated) + data: dict[str, int | float | datetime] = { + "file": round(size / 1e6, 2), + "bytes": size, + "last_updated": last_updated, + } + + return data diff --git a/homeassistant/components/filesize/sensor.py b/homeassistant/components/filesize/sensor.py index 0e60036364074b..c8e5dae5892c3c 100644 --- a/homeassistant/components/filesize/sensor.py +++ b/homeassistant/components/filesize/sensor.py @@ -1,9 +1,8 @@ """Sensor for monitoring the size of a file.""" from __future__ import annotations -from datetime import datetime, timedelta +from datetime import datetime import logging -import os import pathlib from homeassistant.components.sensor import ( @@ -17,14 +16,10 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, - UpdateFailed, -) -import homeassistant.util.dt as dt_util +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN +from .coordinator import FileSizeCoordinator _LOGGER = logging.getLogger(__name__) @@ -80,40 +75,6 @@ async def async_setup_entry( ) -class FileSizeCoordinator(DataUpdateCoordinator): - """Filesize coordinator.""" - - def __init__(self, hass: HomeAssistant, path: str) -> None: - """Initialize filesize coordinator.""" - super().__init__( - hass, - _LOGGER, - name=DOMAIN, - update_interval=timedelta(seconds=60), - always_update=False, - ) - self._path = path - - async def _async_update_data(self) -> dict[str, float | int | datetime]: - """Fetch file information.""" - try: - statinfo = await self.hass.async_add_executor_job(os.stat, self._path) - except OSError as error: - raise UpdateFailed(f"Can not retrieve file statistics {error}") from error - - size = statinfo.st_size - last_updated = dt_util.utc_from_timestamp(statinfo.st_mtime) - - _LOGGER.debug("size %s, last updated %s", size, last_updated) - data: dict[str, int | float | datetime] = { - "file": round(size / 1e6, 2), - "bytes": size, - "last_updated": last_updated, - } - - return data - - class FilesizeEntity(CoordinatorEntity[FileSizeCoordinator], SensorEntity): """Filesize sensor.""" diff --git a/homeassistant/components/flipr/__init__.py b/homeassistant/components/flipr/__init__.py index 81c21a4aa99049..e6d7cb1dd1751b 100644 --- a/homeassistant/components/flipr/__init__.py +++ b/homeassistant/components/flipr/__init__.py @@ -1,27 +1,10 @@ """The Flipr integration.""" -from datetime import timedelta -import logging - -from flipr_api import FliprAPIRestClient -from flipr_api.exceptions import FliprError - from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity import EntityDescription -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, - UpdateFailed, -) - -from .const import ATTRIBUTION, CONF_FLIPR_ID, DOMAIN, MANUFACTURER - -_LOGGER = logging.getLogger(__name__) - -SCAN_INTERVAL = timedelta(minutes=60) +from .const import DOMAIN +from .coordinator import FliprDataUpdateCoordinator PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] @@ -47,58 +30,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -class FliprDataUpdateCoordinator(DataUpdateCoordinator): - """Class to hold Flipr data retrieval.""" - - def __init__(self, hass, entry): - """Initialize.""" - username = entry.data[CONF_EMAIL] - password = entry.data[CONF_PASSWORD] - self.flipr_id = entry.data[CONF_FLIPR_ID] - - # Establishes the connection. - self.client = FliprAPIRestClient(username, password) - self.entry = entry - - super().__init__( - hass, - _LOGGER, - name=f"Flipr data measure for {self.flipr_id}", - update_interval=SCAN_INTERVAL, - ) - - async def _async_update_data(self): - """Fetch data from API endpoint.""" - try: - data = await self.hass.async_add_executor_job( - self.client.get_pool_measure_latest, self.flipr_id - ) - except FliprError as error: - raise UpdateFailed(error) from error - - return data - - -class FliprEntity(CoordinatorEntity): - """Implements a common class elements representing the Flipr component.""" - - _attr_attribution = ATTRIBUTION - _attr_has_entity_name = True - - def __init__( - self, coordinator: DataUpdateCoordinator, description: EntityDescription - ) -> None: - """Initialize Flipr sensor.""" - super().__init__(coordinator) - self.entity_description = description - if coordinator.config_entry: - flipr_id = coordinator.config_entry.data[CONF_FLIPR_ID] - self._attr_unique_id = f"{flipr_id}-{description.key}" - - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, flipr_id)}, - manufacturer=MANUFACTURER, - name=f"Flipr {flipr_id}", - ) diff --git a/homeassistant/components/flipr/binary_sensor.py b/homeassistant/components/flipr/binary_sensor.py index 0597145c2da1dc..677a282e8cb3ce 100644 --- a/homeassistant/components/flipr/binary_sensor.py +++ b/homeassistant/components/flipr/binary_sensor.py @@ -10,8 +10,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import FliprEntity from .const import DOMAIN +from .entity import FliprEntity BINARY_SENSORS_TYPES: tuple[BinarySensorEntityDescription, ...] = ( BinarySensorEntityDescription( diff --git a/homeassistant/components/flipr/coordinator.py b/homeassistant/components/flipr/coordinator.py new file mode 100644 index 00000000000000..d51db645035c04 --- /dev/null +++ b/homeassistant/components/flipr/coordinator.py @@ -0,0 +1,45 @@ +"""DataUpdateCoordinator for flipr integration.""" +from datetime import timedelta +import logging + +from flipr_api import FliprAPIRestClient +from flipr_api.exceptions import FliprError + +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import CONF_FLIPR_ID + +_LOGGER = logging.getLogger(__name__) + + +class FliprDataUpdateCoordinator(DataUpdateCoordinator): + """Class to hold Flipr data retrieval.""" + + def __init__(self, hass, entry): + """Initialize.""" + username = entry.data[CONF_EMAIL] + password = entry.data[CONF_PASSWORD] + self.flipr_id = entry.data[CONF_FLIPR_ID] + + # Establishes the connection. + self.client = FliprAPIRestClient(username, password) + self.entry = entry + + super().__init__( + hass, + _LOGGER, + name=f"Flipr data measure for {self.flipr_id}", + update_interval=timedelta(minutes=60), + ) + + async def _async_update_data(self): + """Fetch data from API endpoint.""" + try: + data = await self.hass.async_add_executor_job( + self.client.get_pool_measure_latest, self.flipr_id + ) + except FliprError as error: + raise UpdateFailed(error) from error + + return data diff --git a/homeassistant/components/flipr/entity.py b/homeassistant/components/flipr/entity.py new file mode 100644 index 00000000000000..6166d727ac714e --- /dev/null +++ b/homeassistant/components/flipr/entity.py @@ -0,0 +1,32 @@ +"""Base entity for the flipr entity.""" +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from .const import ATTRIBUTION, CONF_FLIPR_ID, DOMAIN, MANUFACTURER + + +class FliprEntity(CoordinatorEntity): + """Implements a common class elements representing the Flipr component.""" + + _attr_attribution = ATTRIBUTION + _attr_has_entity_name = True + + def __init__( + self, coordinator: DataUpdateCoordinator, description: EntityDescription + ) -> None: + """Initialize Flipr sensor.""" + super().__init__(coordinator) + self.entity_description = description + if coordinator.config_entry: + flipr_id = coordinator.config_entry.data[CONF_FLIPR_ID] + self._attr_unique_id = f"{flipr_id}-{description.key}" + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, flipr_id)}, + manufacturer=MANUFACTURER, + name=f"Flipr {flipr_id}", + ) diff --git a/homeassistant/components/flipr/sensor.py b/homeassistant/components/flipr/sensor.py index 078e581edda489..a8618b2df879b2 100644 --- a/homeassistant/components/flipr/sensor.py +++ b/homeassistant/components/flipr/sensor.py @@ -12,8 +12,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import FliprEntity from .const import DOMAIN +from .entity import FliprEntity SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( diff --git a/homeassistant/components/flo/switch.py b/homeassistant/components/flo/switch.py index 18a4341db577f6..4456732d125fc8 100644 --- a/homeassistant/components/flo/switch.py +++ b/homeassistant/components/flo/switch.py @@ -100,6 +100,7 @@ def async_update_state(self) -> None: self._attr_is_on = self._device.last_known_valve_state == "open" self.async_write_ha_state() + # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: """When entity is added to hass.""" self.async_on_remove(self._device.async_add_listener(self.async_update_state)) diff --git a/homeassistant/components/flux_led/manifest.json b/homeassistant/components/flux_led/manifest.json index d3274738f75e09..a55ae028342227 100644 --- a/homeassistant/components/flux_led/manifest.json +++ b/homeassistant/components/flux_led/manifest.json @@ -1,7 +1,7 @@ { "domain": "flux_led", "name": "Magic Home", - "codeowners": ["@icemanch", "@bdraco"], + "codeowners": ["@icemanch"], "config_flow": true, "dependencies": ["network"], "dhcp": [ @@ -53,6 +53,5 @@ "documentation": "https://www.home-assistant.io/integrations/flux_led", "iot_class": "local_push", "loggers": ["flux_led"], - "quality_scale": "platinum", - "requirements": ["flux-led==1.0.2"] + "requirements": ["flux-led==1.0.4"] } diff --git a/homeassistant/components/foobot/manifest.json b/homeassistant/components/foobot/manifest.json index 890cd95784cc5a..a517f1fea6fa03 100644 --- a/homeassistant/components/foobot/manifest.json +++ b/homeassistant/components/foobot/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/foobot", "iot_class": "cloud_polling", "loggers": ["foobot_async"], - "requirements": ["foobot-async==1.0.0"] + "requirements": ["foobot_async==1.0.0"] } diff --git a/homeassistant/components/forecast_solar/strings.json b/homeassistant/components/forecast_solar/strings.json index 1413dba23d4357..201a3cd415ceef 100644 --- a/homeassistant/components/forecast_solar/strings.json +++ b/homeassistant/components/forecast_solar/strings.json @@ -22,7 +22,7 @@ "init": { "description": "These values allow tweaking the Forecast.Solar result. Please refer to the documentation if a field is unclear.", "data": { - "api_key": "Forecast.Solar API Key (optional)", + "api_key": "[%key:common::config_flow::data::api_key%]", "azimuth": "[%key:component::forecast_solar::config::step::user::data::azimuth%]", "damping_morning": "Damping factor: adjusts the results in the morning", "damping_evening": "Damping factor: adjusts the results in the evening", diff --git a/homeassistant/components/freebox/const.py b/homeassistant/components/freebox/const.py index 59dce75649be7c..5bed7b3456aeba 100644 --- a/homeassistant/components/freebox/const.py +++ b/homeassistant/components/freebox/const.py @@ -85,4 +85,7 @@ class FreeboxHomeCategory(enum.StrEnum): HOME_COMPATIBLE_CATEGORIES = [ FreeboxHomeCategory.CAMERA, + FreeboxHomeCategory.DWS, + FreeboxHomeCategory.KFB, + FreeboxHomeCategory.PIR, ] diff --git a/homeassistant/components/freebox/router.py b/homeassistant/components/freebox/router.py index 7c83e98054051f..cd5862a2f802be 100644 --- a/homeassistant/components/freebox/router.py +++ b/homeassistant/components/freebox/router.py @@ -156,7 +156,12 @@ async def _update_disks_sensors(self) -> None: fbx_disks: list[dict[str, Any]] = await self._api.storage.get_disks() or [] for fbx_disk in fbx_disks: - self.disks[fbx_disk["id"]] = fbx_disk + disk: dict[str, Any] = {**fbx_disk} + disk_part: dict[int, dict[str, Any]] = {} + for fbx_disk_part in fbx_disk["partitions"]: + disk_part[fbx_disk_part["id"]] = fbx_disk_part + disk["partitions"] = disk_part + self.disks[fbx_disk["id"]] = disk async def _update_raids_sensors(self) -> None: """Update Freebox raids.""" diff --git a/homeassistant/components/freebox/sensor.py b/homeassistant/components/freebox/sensor.py index 901bfc6319985d..4e7c3910c54fba 100644 --- a/homeassistant/components/freebox/sensor.py +++ b/homeassistant/components/freebox/sensor.py @@ -95,7 +95,7 @@ async def async_setup_entry( entities.extend( FreeboxDiskSensor(router, disk, partition, description) for disk in router.disks.values() - for partition in disk["partitions"] + for partition in disk["partitions"].values() for description in DISK_PARTITION_SENSORS ) @@ -197,7 +197,8 @@ def __init__( ) -> None: """Initialize a Freebox disk sensor.""" super().__init__(router, description) - self._partition = partition + self._disk_id = disk["id"] + self._partition_id = partition["id"] self._attr_name = f"{partition['label']} {description.name}" self._attr_unique_id = ( f"{router.mac} {description.key} {disk['id']} {partition['id']}" @@ -218,10 +219,10 @@ def __init__( def async_update_state(self) -> None: """Update the Freebox disk sensor.""" value = None - if self._partition.get("total_bytes"): - value = round( - self._partition["free_bytes"] * 100 / self._partition["total_bytes"], 2 - ) + disk: dict[str, Any] = self._router.disks[self._disk_id] + partition: dict[str, Any] = disk["partitions"][self._partition_id] + if partition.get("total_bytes"): + value = round(partition["free_bytes"] * 100 / partition["total_bytes"], 2) self._attr_native_value = value diff --git a/homeassistant/components/fritz/common.py b/homeassistant/components/fritz/common.py index 6977377812160b..2abba137fbf359 100644 --- a/homeassistant/components/fritz/common.py +++ b/homeassistant/components/fritz/common.py @@ -566,7 +566,7 @@ async def async_scan_devices(self, now: datetime | None = None) -> None: self.fritz_hosts.get_mesh_topology ) ): - # pylint: disable=broad-exception-raised + # pylint: disable-next=broad-exception-raised raise Exception("Mesh supported but empty topology reported") except FritzActionError: self.mesh_role = MeshRoles.SLAVE @@ -1096,7 +1096,7 @@ def device_info(self) -> DeviceInfo: class FritzRequireKeysMixin: """Fritz entity description mix in.""" - value_fn: Callable[[FritzStatus, Any], Any] + value_fn: Callable[[FritzStatus, Any], Any] | None @dataclass @@ -1118,9 +1118,12 @@ def __init__( ) -> None: """Init device info class.""" super().__init__(avm_wrapper) - self.async_on_remove( - avm_wrapper.register_entity_updates(description.key, description.value_fn) - ) + if description.value_fn is not None: + self.async_on_remove( + avm_wrapper.register_entity_updates( + description.key, description.value_fn + ) + ) self.entity_description = description self._device_name = device_name self._attr_unique_id = f"{avm_wrapper.unique_id}-{description.key}" diff --git a/homeassistant/components/fritz/manifest.json b/homeassistant/components/fritz/manifest.json index 8d52115d49b3dd..d8d8f6b94bfce3 100644 --- a/homeassistant/components/fritz/manifest.json +++ b/homeassistant/components/fritz/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/fritz", "iot_class": "local_polling", "loggers": ["fritzconnection"], - "requirements": ["fritzconnection[qr]==1.12.2", "xmltodict==0.13.0"], + "requirements": ["fritzconnection[qr]==1.13.2", "xmltodict==0.13.0"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:fritzbox:1" diff --git a/homeassistant/components/fritz/update.py b/homeassistant/components/fritz/update.py index 03cffc3cae6441..80cbe1f4c5c1d6 100644 --- a/homeassistant/components/fritz/update.py +++ b/homeassistant/components/fritz/update.py @@ -1,20 +1,31 @@ """Support for AVM FRITZ!Box update platform.""" from __future__ import annotations +from dataclasses import dataclass import logging from typing import Any -from homeassistant.components.update import UpdateEntity, UpdateEntityFeature +from homeassistant.components.update import ( + UpdateEntity, + UpdateEntityDescription, + UpdateEntityFeature, +) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .common import AvmWrapper, FritzBoxBaseEntity +from .common import AvmWrapper, FritzBoxBaseCoordinatorEntity, FritzEntityDescription from .const import DOMAIN _LOGGER = logging.getLogger(__name__) +@dataclass +class FritzUpdateEntityDescription(UpdateEntityDescription, FritzEntityDescription): + """Describes Fritz update entity.""" + + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: @@ -27,11 +38,13 @@ async def async_setup_entry( async_add_entities(entities) -class FritzBoxUpdateEntity(FritzBoxBaseEntity, UpdateEntity): +class FritzBoxUpdateEntity(FritzBoxBaseCoordinatorEntity, UpdateEntity): """Mixin for update entity specific attributes.""" + _attr_entity_category = EntityCategory.CONFIG _attr_supported_features = UpdateEntityFeature.INSTALL _attr_title = "FRITZ!OS" + entity_description: FritzUpdateEntityDescription def __init__( self, @@ -39,29 +52,30 @@ def __init__( device_friendly_name: str, ) -> None: """Init FRITZ!Box connectivity class.""" - self._attr_name = f"{device_friendly_name} FRITZ!OS" - self._attr_unique_id = f"{avm_wrapper.unique_id}-update" - super().__init__(avm_wrapper, device_friendly_name) + description = FritzUpdateEntityDescription( + key="update", name="FRITZ!OS", value_fn=None + ) + super().__init__(avm_wrapper, device_friendly_name, description) @property def installed_version(self) -> str | None: """Version currently in use.""" - return self._avm_wrapper.current_firmware + return self.coordinator.current_firmware @property def latest_version(self) -> str | None: """Latest version available for install.""" - if self._avm_wrapper.update_available: - return self._avm_wrapper.latest_firmware - return self._avm_wrapper.current_firmware + if self.coordinator.update_available: + return self.coordinator.latest_firmware + return self.coordinator.current_firmware @property def release_url(self) -> str | None: """URL to the full release notes of the latest version available.""" - return self._avm_wrapper.release_url + return self.coordinator.release_url async def async_install( self, version: str | None, backup: bool, **kwargs: Any ) -> None: """Install an update.""" - await self._avm_wrapper.async_trigger_firmware_update() + await self.coordinator.async_trigger_firmware_update() diff --git a/homeassistant/components/fritzbox/manifest.json b/homeassistant/components/fritzbox/manifest.json index 35b78e91f81669..fdf38d88439ffb 100644 --- a/homeassistant/components/fritzbox/manifest.json +++ b/homeassistant/components/fritzbox/manifest.json @@ -7,6 +7,7 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["pyfritzhome"], + "quality_scale": "gold", "requirements": ["pyfritzhome==0.6.9"], "ssdp": [ { diff --git a/homeassistant/components/fritzbox_callmonitor/manifest.json b/homeassistant/components/fritzbox_callmonitor/manifest.json index c3c305ab07ece8..4e5c60091c9025 100644 --- a/homeassistant/components/fritzbox_callmonitor/manifest.json +++ b/homeassistant/components/fritzbox_callmonitor/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["fritzconnection"], - "requirements": ["fritzconnection[qr]==1.12.2"] + "requirements": ["fritzconnection[qr]==1.13.2"] } diff --git a/homeassistant/components/fritzbox_callmonitor/sensor.py b/homeassistant/components/fritzbox_callmonitor/sensor.py index 43cdb29f85f0a5..cc239895c38238 100644 --- a/homeassistant/components/fritzbox_callmonitor/sensor.py +++ b/homeassistant/components/fritzbox_callmonitor/sensor.py @@ -12,7 +12,7 @@ from fritzconnection.core.fritzmonitor import FritzMonitor -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant @@ -82,6 +82,9 @@ class FritzBoxCallSensor(SensorEntity): """Implementation of a Fritz!Box call monitor.""" _attr_icon = ICON_PHONE + _attr_translation_key = DOMAIN + _attr_device_class = SensorDeviceClass.ENUM + _attr_options = list(CallState) def __init__( self, @@ -189,7 +192,11 @@ def connect(self) -> None: _LOGGER.debug("Setting up socket connection") try: self.connection = FritzMonitor(address=self.host, port=self.port) - kwargs: dict[str, Any] = {"event_queue": self.connection.start()} + kwargs: dict[str, Any] = { + "event_queue": self.connection.start( + reconnect_tries=50, reconnect_delay=120 + ) + } Thread(target=self._process_events, kwargs=kwargs).start() except OSError as err: self.connection = None diff --git a/homeassistant/components/fritzbox_callmonitor/strings.json b/homeassistant/components/fritzbox_callmonitor/strings.json index 6b2fa2943f9961..89f049bfbe90d2 100644 --- a/homeassistant/components/fritzbox_callmonitor/strings.json +++ b/homeassistant/components/fritzbox_callmonitor/strings.json @@ -37,5 +37,17 @@ "error": { "malformed_prefixes": "Prefixes are malformed, please check their format." } + }, + "entity": { + "sensor": { + "fritzbox_callmonitor": { + "state": { + "ringing": "Ringing", + "dialing": "Dialing", + "talking": "Talking", + "idle": "[%key:common::state::idle%]" + } + } + } } } diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 3b46f568d3eccb..6291e3a237e9c4 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==20230901.0"] + "requirements": ["home-assistant-frontend==20230911.0"] } diff --git a/homeassistant/components/garadget/cover.py b/homeassistant/components/garadget/cover.py index 826f21e9f8886a..6d9705cee756ec 100644 --- a/homeassistant/components/garadget/cover.py +++ b/homeassistant/components/garadget/cover.py @@ -92,6 +92,8 @@ def setup_platform( class GaradgetCover(CoverEntity): """Representation of a Garadget cover.""" + _attr_device_class = CoverDeviceClass.GARAGE + def __init__(self, hass, args): """Initialize the cover.""" self.particle_url = "https://api.particle.io" @@ -174,11 +176,6 @@ def is_closed(self) -> bool | None: return None return self._state == STATE_CLOSED - @property - def device_class(self) -> CoverDeviceClass: - """Return the class of this device, from component DEVICE_CLASSES.""" - return CoverDeviceClass.GARAGE - def get_token(self): """Get new token for usage during this session.""" args = { diff --git a/homeassistant/components/gardena_bluetooth/manifest.json b/homeassistant/components/gardena_bluetooth/manifest.json index 5d1c1888586017..bcbb25d55a2640 100644 --- a/homeassistant/components/gardena_bluetooth/manifest.json +++ b/homeassistant/components/gardena_bluetooth/manifest.json @@ -13,5 +13,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/gardena_bluetooth", "iot_class": "local_polling", - "requirements": ["gardena_bluetooth==1.3.0"] + "requirements": ["gardena-bluetooth==1.4.0"] } diff --git a/homeassistant/components/geniushub/__init__.py b/homeassistant/components/geniushub/__init__.py index bed622eebf6041..955c76fe0fc8e2 100644 --- a/homeassistant/components/geniushub/__init__.py +++ b/homeassistant/components/geniushub/__init__.py @@ -212,11 +212,10 @@ async def async_update(self, now, **kwargs) -> None: def make_debug_log_entries(self) -> None: """Make any useful debug log entries.""" - # pylint: disable=protected-access _LOGGER.debug( "Raw JSON: \n\nclient._zones = %s \n\nclient._devices = %s", - self.client._zones, - self.client._devices, + self.client._zones, # pylint: disable=protected-access + self.client._devices, # pylint: disable=protected-access ) @@ -309,7 +308,7 @@ async def _refresh(self, payload: dict | None = None) -> None: mode = payload["data"][ATTR_ZONE_MODE] - # pylint: disable=protected-access + # pylint: disable-next=protected-access if mode == "footprint" and not self._zone._has_pir: raise TypeError( f"'{self.entity_id}' cannot support footprint mode (it has no PIR)" diff --git a/homeassistant/components/geniushub/sensor.py b/homeassistant/components/geniushub/sensor.py index 06237b6e8d5691..22d95be079ee31 100644 --- a/homeassistant/components/geniushub/sensor.py +++ b/homeassistant/components/geniushub/sensor.py @@ -47,6 +47,9 @@ async def async_setup_platform( class GeniusBattery(GeniusDevice, SensorEntity): """Representation of a Genius Hub sensor.""" + _attr_device_class = SensorDeviceClass.BATTERY + _attr_native_unit_of_measurement = PERCENTAGE + def __init__(self, broker, device, state_attr) -> None: """Initialize the sensor.""" super().__init__(broker, device) @@ -80,16 +83,6 @@ def icon(self) -> str: return icon - @property - def device_class(self) -> SensorDeviceClass: - """Return the device class of the sensor.""" - return SensorDeviceClass.BATTERY - - @property - def native_unit_of_measurement(self) -> str: - """Return the unit of measurement of the sensor.""" - return PERCENTAGE - @property def native_value(self) -> str: """Return the state of the sensor.""" diff --git a/homeassistant/components/glances/config_flow.py b/homeassistant/components/glances/config_flow.py index 04e133248a6399..58b81bc088e310 100644 --- a/homeassistant/components/glances/config_flow.py +++ b/homeassistant/components/glances/config_flow.py @@ -61,11 +61,14 @@ async def async_step_user( """Handle the initial step.""" errors = {} if user_input is not None: - self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) + self._async_abort_entries_match( + {CONF_HOST: user_input[CONF_HOST], CONF_PORT: user_input[CONF_PORT]} + ) try: await validate_input(self.hass, user_input) return self.async_create_entry( - title=user_input[CONF_HOST], data=user_input + title=f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}", + data=user_input, ) except CannotConnect: errors["base"] = "cannot_connect" diff --git a/homeassistant/components/glances/coordinator.py b/homeassistant/components/glances/coordinator.py index 24a2e23a013f75..8d2bd0daaa3fac 100644 --- a/homeassistant/components/glances/coordinator.py +++ b/homeassistant/components/glances/coordinator.py @@ -35,6 +35,7 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry, api: Glances) -> Non async def _async_update_data(self) -> dict[str, Any]: """Get the latest data from the Glances REST API.""" try: - return await self.api.get_ha_sensor_data() + data = await self.api.get_ha_sensor_data() except exceptions.GlancesApiError as err: raise UpdateFailed from err + return data or {} diff --git a/homeassistant/components/glances/sensor.py b/homeassistant/components/glances/sensor.py index cd9c3a9135dc18..78aa5ffbf0a16f 100644 --- a/homeassistant/components/glances/sensor.py +++ b/homeassistant/components/glances/sensor.py @@ -2,6 +2,7 @@ from __future__ import annotations from dataclasses import dataclass +from typing import cast from homeassistant.components.sensor import ( SensorDeviceClass, @@ -346,5 +347,7 @@ def native_value(self) -> StateType: value = self.coordinator.data[self.entity_description.type] if isinstance(value.get(self._sensor_name_prefix), dict): - return value[self._sensor_name_prefix][self.entity_description.key] - return value[self.entity_description.key] + return cast( + StateType, value[self._sensor_name_prefix][self.entity_description.key] + ) + return cast(StateType, value[self.entity_description.key]) diff --git a/homeassistant/components/gogogate2/manifest.json b/homeassistant/components/gogogate2/manifest.json index faebcf7e35328a..40633537ddf44a 100644 --- a/homeassistant/components/gogogate2/manifest.json +++ b/homeassistant/components/gogogate2/manifest.json @@ -14,5 +14,5 @@ }, "iot_class": "local_polling", "loggers": ["ismartgate"], - "requirements": ["ismartgate==5.0.0"] + "requirements": ["ismartgate==5.0.1"] } diff --git a/homeassistant/components/goodwe/coordinator.py b/homeassistant/components/goodwe/coordinator.py index 0ae064e0e97c8d..ac91fba787dbc5 100644 --- a/homeassistant/components/goodwe/coordinator.py +++ b/homeassistant/components/goodwe/coordinator.py @@ -30,7 +30,6 @@ def __init__( _LOGGER, name=entry.title, update_interval=SCAN_INTERVAL, - update_method=self._async_update_data, ) self.inverter: Inverter = inverter self._last_data: dict[str, Any] = {} diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py index 49d130d665692c..c1b505b2bd49c2 100644 --- a/homeassistant/components/google_assistant/helpers.py +++ b/homeassistant/components/google_assistant/helpers.py @@ -5,6 +5,7 @@ from asyncio import gather from collections.abc import Callable, Mapping from datetime import datetime, timedelta +from functools import lru_cache from http import HTTPStatus import logging import pprint @@ -490,9 +491,34 @@ def get_google_type(domain, device_class): return typ if typ is not None else DOMAIN_TO_GOOGLE_TYPES[domain] +@lru_cache(maxsize=4096) +def supported_traits_for_state(state: State) -> list[type[trait._Trait]]: + """Return all supported traits for state.""" + domain = state.domain + attributes = state.attributes + features = attributes.get(ATTR_SUPPORTED_FEATURES, 0) + + if not isinstance(features, int): + _LOGGER.warning( + "Entity %s contains invalid supported_features value %s", + state.entity_id, + features, + ) + return [] + + device_class = state.attributes.get(ATTR_DEVICE_CLASS) + return [ + Trait + for Trait in trait.TRAITS + if Trait.supported(domain, features, device_class, attributes) + ] + + class GoogleEntity: """Adaptation of Entity expressed in Google's terms.""" + __slots__ = ("hass", "config", "state", "_traits") + def __init__( self, hass: HomeAssistant, config: AbstractConfig, state: State ) -> None: @@ -502,6 +528,10 @@ def __init__( self.state = state self._traits: list[trait._Trait] | None = None + def __repr__(self) -> str: + """Return the representation.""" + return f"" + @property def entity_id(self): """Return entity ID.""" @@ -512,26 +542,10 @@ def traits(self) -> list[trait._Trait]: """Return traits for entity.""" if self._traits is not None: return self._traits - state = self.state - domain = state.domain - attributes = state.attributes - features = attributes.get(ATTR_SUPPORTED_FEATURES, 0) - - if not isinstance(features, int): - _LOGGER.warning( - "Entity %s contains invalid supported_features value %s", - self.entity_id, - features, - ) - return [] - - device_class = state.attributes.get(ATTR_DEVICE_CLASS) - self._traits = [ Trait(self.hass, state, self.config) - for Trait in trait.TRAITS - if Trait.supported(domain, features, device_class, attributes) + for Trait in supported_traits_for_state(state) ] return self._traits @@ -554,18 +568,8 @@ def should_expose_local(self) -> bool: @callback def is_supported(self) -> bool: - """Return if the entity is supported by Google.""" - features: int | None = self.state.attributes.get(ATTR_SUPPORTED_FEATURES) - - result = self.config.is_supported_cache.get(self.entity_id) - - if result is None or result[0] != features: - result = self.config.is_supported_cache[self.entity_id] = ( - features, - bool(self.traits()), - ) - - return result[1] + """Return if entity is supported.""" + return bool(self.traits()) @callback def might_2fa(self) -> bool: @@ -725,19 +729,64 @@ def deep_update(target, source): return target +@callback +def async_get_google_entity_if_supported_cached( + hass: HomeAssistant, config: AbstractConfig, state: State +) -> GoogleEntity | None: + """Return a GoogleEntity if entity is supported checking the cache first. + + This function will check the cache, and call async_get_google_entity_if_supported + if the entity is not in the cache, which will update the cache. + """ + entity_id = state.entity_id + is_supported_cache = config.is_supported_cache + features: int | None = state.attributes.get(ATTR_SUPPORTED_FEATURES) + if result := is_supported_cache.get(entity_id): + cached_features, supported = result + if cached_features == features: + return GoogleEntity(hass, config, state) if supported else None + # Cache miss, check if entity is supported + return async_get_google_entity_if_supported(hass, config, state) + + +@callback +def async_get_google_entity_if_supported( + hass: HomeAssistant, config: AbstractConfig, state: State +) -> GoogleEntity | None: + """Return a GoogleEntity if entity is supported. + + This function will update the cache, but it does not check the cache first. + """ + features: int | None = state.attributes.get(ATTR_SUPPORTED_FEATURES) + entity = GoogleEntity(hass, config, state) + is_supported = bool(entity.traits()) + config.is_supported_cache[state.entity_id] = (features, is_supported) + return entity if is_supported else None + + @callback def async_get_entities( hass: HomeAssistant, config: AbstractConfig ) -> list[GoogleEntity]: """Return all entities that are supported by Google.""" - entities = [] + entities: list[GoogleEntity] = [] + is_supported_cache = config.is_supported_cache for state in hass.states.async_all(): - if state.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: + entity_id = state.entity_id + if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: continue - - entity = GoogleEntity(hass, config, state) - - if entity.is_supported(): + # Check check inlined for performance to avoid + # function calls for every entity since we enumerate + # the entire state machine here + features: int | None = state.attributes.get(ATTR_SUPPORTED_FEATURES) + if result := is_supported_cache.get(entity_id): + cached_features, supported = result + if cached_features == features: + if supported: + entities.append(GoogleEntity(hass, config, state)) + continue + # Cached features don't match, fall through to check + # if the entity is supported and update the cache. + if entity := async_get_google_entity_if_supported(hass, config, state): entities.append(entity) - return entities diff --git a/homeassistant/components/google_assistant/report_state.py b/homeassistant/components/google_assistant/report_state.py index 5248ce7c4da0ae..52228bb8715d4f 100644 --- a/homeassistant/components/google_assistant/report_state.py +++ b/homeassistant/components/google_assistant/report_state.py @@ -6,13 +6,17 @@ from typing import Any from homeassistant.const import MATCH_ALL -from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, State, callback from homeassistant.helpers.event import async_call_later, async_track_state_change from homeassistant.helpers.significant_change import create_checker from .const import DOMAIN from .error import SmartHomeError -from .helpers import AbstractConfig, GoogleEntity, async_get_entities +from .helpers import ( + AbstractConfig, + async_get_entities, + async_get_google_entity_if_supported_cached, +) # Time to wait until the homegraph updates # https://github.com/actions-on-google/smart-home-nodejs/issues/196#issuecomment-439156639 @@ -54,8 +58,10 @@ async def report_states(now=None): report_states_job = HassJob(report_states) - async def async_entity_state_listener(changed_entity, old_state, new_state): - nonlocal unsub_pending + async def async_entity_state_listener( + changed_entity: str, old_state: State | None, new_state: State | None + ) -> None: + nonlocal unsub_pending, checker if not hass.is_running: return @@ -66,9 +72,11 @@ async def async_entity_state_listener(changed_entity, old_state, new_state): if not google_config.should_expose(new_state): return - entity = GoogleEntity(hass, google_config, new_state) - - if not entity.is_supported(): + if not ( + entity := async_get_google_entity_if_supported_cached( + hass, google_config, new_state + ) + ): return try: @@ -77,6 +85,7 @@ async def async_entity_state_listener(changed_entity, old_state, new_state): _LOGGER.debug("Not reporting state for %s: %s", changed_entity, err.code) return + assert checker is not None if not checker.async_is_significant_change(new_state, extra_arg=entity_data): return diff --git a/homeassistant/components/gpsd/sensor.py b/homeassistant/components/gpsd/sensor.py index 28ca9d3f075240..64b86434c3c5d0 100644 --- a/homeassistant/components/gpsd/sensor.py +++ b/homeassistant/components/gpsd/sensor.py @@ -3,6 +3,7 @@ import logging import socket +from typing import Any from gps3.agps3threaded import AGPS3mechanism import voluptuous as vol @@ -48,9 +49,9 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the GPSD component.""" - name = config.get(CONF_NAME) - host = config.get(CONF_HOST) - port = config.get(CONF_PORT) + name = config[CONF_NAME] + host = config[CONF_HOST] + port = config[CONF_PORT] # Will hopefully be possible with the next gps3 update # https://github.com/wadda/gps3/issues/11 @@ -77,7 +78,13 @@ def setup_platform( class GpsdSensor(SensorEntity): """Representation of a GPS receiver available via GPSD.""" - def __init__(self, hass, name, host, port): + def __init__( + self, + hass: HomeAssistant, + name: str, + host: str, + port: int, + ) -> None: """Initialize the GPSD sensor.""" self.hass = hass self._name = name @@ -89,12 +96,12 @@ def __init__(self, hass, name, host, port): self.agps_thread.run_thread() @property - def name(self): + def name(self) -> str: """Return the name.""" return self._name @property - def native_value(self): + def native_value(self) -> str | None: """Return the state of GPSD.""" if self.agps_thread.data_stream.mode == 3: return "3D Fix" @@ -103,7 +110,7 @@ def native_value(self): return None @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes of the GPS.""" return { ATTR_LATITUDE: self.agps_thread.data_stream.lat, @@ -114,3 +121,12 @@ def extra_state_attributes(self): ATTR_CLIMB: self.agps_thread.data_stream.climb, ATTR_MODE: self.agps_thread.data_stream.mode, } + + @property + def icon(self) -> str: + """Return the icon of the sensor.""" + mode = self.agps_thread.data_stream.mode + + if isinstance(mode, int) and mode >= 2: + return "mdi:crosshairs-gps" + return "mdi:crosshairs" diff --git a/homeassistant/components/greeneye_monitor/manifest.json b/homeassistant/components/greeneye_monitor/manifest.json index 33a4947c01d230..fcf4d004d26565 100644 --- a/homeassistant/components/greeneye_monitor/manifest.json +++ b/homeassistant/components/greeneye_monitor/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/greeneye_monitor", "iot_class": "local_push", "loggers": ["greeneye"], - "requirements": ["greeneye-monitor==3.0.3"] + "requirements": ["greeneye_monitor==3.0.3"] } diff --git a/homeassistant/components/group/__init__.py b/homeassistant/components/group/__init__.py index ef011c4308af69..364ef15fa5e36a 100644 --- a/homeassistant/components/group/__init__.py +++ b/homeassistant/components/group/__init__.py @@ -42,7 +42,6 @@ async_track_state_change_event, ) from homeassistant.helpers.integration_platform import ( - async_process_integration_platform_for_component, async_process_integration_platforms, ) from homeassistant.helpers.reload import async_reload_integration_platforms @@ -285,8 +284,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: if DOMAIN not in hass.data: hass.data[DOMAIN] = EntityComponent[Group](_LOGGER, DOMAIN, hass) - await async_process_integration_platform_for_component(hass, DOMAIN) - component: EntityComponent[Group] = hass.data[DOMAIN] hass.data[REG_KEY] = GroupIntegrationRegistry() @@ -472,6 +469,8 @@ async def _async_process_config(hass: HomeAssistant, config: ConfigType) -> None class GroupEntity(Entity): """Representation of a Group of entities.""" + _unrecorded_attributes = frozenset({ATTR_ENTITY_ID}) + _attr_should_poll = False _entity_ids: list[str] @@ -560,6 +559,8 @@ def async_update_supported_features( class Group(Entity): """Track a group of entity ids.""" + _unrecorded_attributes = frozenset({ATTR_ENTITY_ID, ATTR_ORDER, ATTR_AUTO}) + _attr_should_poll = False tracking: tuple[str, ...] trackable: tuple[str, ...] diff --git a/homeassistant/components/group/config_flow.py b/homeassistant/components/group/config_flow.py index 9eb973b960970b..93160b0db5bb06 100644 --- a/homeassistant/components/group/config_flow.py +++ b/homeassistant/components/group/config_flow.py @@ -361,6 +361,7 @@ def ws_start_preview( msg: dict[str, Any], ) -> None: """Generate a preview.""" + entity_registry_entry: er.RegistryEntry | None = None if msg["flow_type"] == "config_flow": flow_status = hass.config_entries.flow.async_get(msg["flow_id"]) group_type = flow_status["step_id"] @@ -370,12 +371,17 @@ def ws_start_preview( name = validated["name"] else: flow_status = hass.config_entries.options.async_get(msg["flow_id"]) - config_entry = hass.config_entries.async_get_entry(flow_status["handler"]) + config_entry_id = flow_status["handler"] + config_entry = hass.config_entries.async_get_entry(config_entry_id) if not config_entry: raise HomeAssistantError group_type = config_entry.options["group_type"] name = config_entry.options["name"] validated = PREVIEW_OPTIONS_SCHEMA[group_type](msg["user_input"]) + entity_registry = er.async_get(hass) + entries = er.async_entries_for_config_entry(entity_registry, config_entry_id) + if entries: + entity_registry_entry = entries[0] @callback def async_preview_updated(state: str, attributes: Mapping[str, Any]) -> None: @@ -388,6 +394,7 @@ def async_preview_updated(state: str, attributes: Mapping[str, Any]) -> None: preview_entity = CREATE_PREVIEW_ENTITY[group_type](name, validated) preview_entity.hass = hass + preview_entity.registry_entry = entity_registry_entry connection.send_result(msg["id"]) connection.subscriptions[msg["id"]] = preview_entity.async_start_preview( diff --git a/homeassistant/components/group/cover.py b/homeassistant/components/group/cover.py index dbb49222bb0b93..d22184c0922f48 100644 --- a/homeassistant/components/group/cover.py +++ b/homeassistant/components/group/cover.py @@ -17,7 +17,6 @@ ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_ASSUMED_STATE, ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, CONF_ENTITIES, @@ -44,7 +43,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import GroupEntity -from .util import attribute_equal, reduce_attribute +from .util import reduce_attribute KEY_OPEN_CLOSE = "open_close" KEY_STOP = "stop" @@ -116,7 +115,6 @@ class CoverGroup(GroupEntity, CoverEntity): _attr_is_opening: bool | None = False _attr_is_closing: bool | None = False _attr_current_cover_position: int | None = 100 - _attr_assumed_state: bool = True def __init__(self, unique_id: str | None, name: str, entities: list[str]) -> None: """Initialize a CoverGroup entity.""" @@ -251,8 +249,6 @@ async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: @callback def async_update_group_state(self) -> None: """Update state and attributes.""" - self._attr_assumed_state = False - states = [ state.state for entity_id in self._entity_ids @@ -293,9 +289,6 @@ def async_update_group_state(self) -> None: self._attr_current_cover_position = reduce_attribute( position_states, ATTR_CURRENT_POSITION ) - self._attr_assumed_state |= not attribute_equal( - position_states, ATTR_CURRENT_POSITION - ) tilt_covers = self._tilts[KEY_POSITION] all_tilt_states = [self.hass.states.get(x) for x in tilt_covers] @@ -303,9 +296,6 @@ def async_update_group_state(self) -> None: self._attr_current_cover_tilt_position = reduce_attribute( tilt_states, ATTR_CURRENT_TILT_POSITION ) - self._attr_assumed_state |= not attribute_equal( - tilt_states, ATTR_CURRENT_TILT_POSITION - ) supported_features = CoverEntityFeature(0) if self._covers[KEY_OPEN_CLOSE]: @@ -322,11 +312,3 @@ def async_update_group_state(self) -> None: if self._tilts[KEY_POSITION]: supported_features |= CoverEntityFeature.SET_TILT_POSITION self._attr_supported_features = supported_features - - if not self._attr_assumed_state: - for entity_id in self._entity_ids: - if (state := self.hass.states.get(entity_id)) is None: - continue - if state and state.attributes.get(ATTR_ASSUMED_STATE): - self._attr_assumed_state = True - break diff --git a/homeassistant/components/group/fan.py b/homeassistant/components/group/fan.py index 4ee788c840249d..4e3bb824266193 100644 --- a/homeassistant/components/group/fan.py +++ b/homeassistant/components/group/fan.py @@ -25,7 +25,6 @@ ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_ASSUMED_STATE, ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, CONF_ENTITIES, @@ -41,12 +40,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import GroupEntity -from .util import ( - attribute_equal, - most_frequent_attribute, - reduce_attribute, - states_equal, -) +from .util import attribute_equal, most_frequent_attribute, reduce_attribute SUPPORTED_FLAGS = { FanEntityFeature.SET_SPEED, @@ -110,7 +104,6 @@ class FanGroup(GroupEntity, FanEntity): """Representation of a FanGroup.""" _attr_available: bool = False - _attr_assumed_state: bool = True def __init__(self, unique_id: str | None, name: str, entities: list[str]) -> None: """Initialize a FanGroup entity.""" @@ -243,19 +236,16 @@ def _set_attr_most_frequent(self, attr: str, flag: int, entity_attr: str) -> Non """Set an attribute based on most frequent supported entities attributes.""" states = self._async_states_by_support_flag(flag) setattr(self, attr, most_frequent_attribute(states, entity_attr)) - self._attr_assumed_state |= not attribute_equal(states, entity_attr) @callback def async_update_group_state(self) -> None: """Update state and attributes.""" - self._attr_assumed_state = False states = [ state for entity_id in self._entity_ids if (state := self.hass.states.get(entity_id)) is not None ] - self._attr_assumed_state |= not states_equal(states) # Set group as unavailable if all members are unavailable or missing self._attr_available = any(state.state != STATE_UNAVAILABLE for state in states) @@ -274,9 +264,6 @@ def async_update_group_state(self) -> None: FanEntityFeature.SET_SPEED ) self._percentage = reduce_attribute(percentage_states, ATTR_PERCENTAGE) - self._attr_assumed_state |= not attribute_equal( - percentage_states, ATTR_PERCENTAGE - ) if ( percentage_states and percentage_states[0].attributes.get(ATTR_PERCENTAGE_STEP) @@ -301,6 +288,3 @@ def async_update_group_state(self) -> None: ior, [feature for feature in SUPPORTED_FLAGS if self._fans[feature]], 0 ) ) - self._attr_assumed_state |= any( - state.attributes.get(ATTR_ASSUMED_STATE) for state in states - ) diff --git a/homeassistant/components/group/media_player.py b/homeassistant/components/group/media_player.py index 3960f400614f4e..bc238519cfa90b 100644 --- a/homeassistant/components/group/media_player.py +++ b/homeassistant/components/group/media_player.py @@ -122,6 +122,8 @@ def async_create_preview_media_player( class MediaPlayerGroup(MediaPlayerEntity): """Representation of a Media Group.""" + _unrecorded_attributes = frozenset({ATTR_ENTITY_ID}) + _attr_available: bool = False _attr_should_poll = False diff --git a/homeassistant/components/group/recorder.py b/homeassistant/components/group/recorder.py deleted file mode 100644 index 9138b4ef348ef9..00000000000000 --- a/homeassistant/components/group/recorder.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.core import HomeAssistant, callback - -from . import ATTR_AUTO, ATTR_ENTITY_ID, ATTR_ORDER - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude static attributes from being recorded in the database.""" - return { - ATTR_ENTITY_ID, - ATTR_ORDER, - ATTR_AUTO, - } diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 72fb5ce5110eeb..270309149ef3ce 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -6,6 +6,7 @@ from datetime import datetime, timedelta import logging import os +import re from typing import Any, NamedTuple import voluptuous as vol @@ -149,10 +150,12 @@ SERVICE_RESTORE_FULL = "restore_full" SERVICE_RESTORE_PARTIAL = "restore_partial" +VALID_ADDON_SLUG = vol.Match(re.compile(r"^[-_.A-Za-z0-9]+$")) + def valid_addon(value: Any) -> str: """Validate value is a valid addon slug.""" - value = cv.slug(value) + value = VALID_ADDON_SLUG(value) hass: HomeAssistant | None = None with suppress(HomeAssistantError): diff --git a/homeassistant/components/hdmi_cec/__init__.py b/homeassistant/components/hdmi_cec/__init__.py index 459f03edfbbec5..19621e28d032a5 100644 --- a/homeassistant/components/hdmi_cec/__init__.py +++ b/homeassistant/components/hdmi_cec/__init__.py @@ -123,11 +123,12 @@ SERVICE_POWER_ON = "power_on" SERVICE_STANDBY = "standby" -# pylint: disable=unnecessary-lambda DEVICE_SCHEMA: vol.Schema = vol.Schema( { vol.All(cv.positive_int): vol.Any( - lambda devices: DEVICE_SCHEMA(devices), cv.string + # pylint: disable-next=unnecessary-lambda + lambda devices: DEVICE_SCHEMA(devices), + cv.string, ) } ) diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py index e2487e90a991a1..8502dec28faf70 100644 --- a/homeassistant/components/heos/media_player.py +++ b/homeassistant/components/heos/media_player.py @@ -114,6 +114,8 @@ class HeosMediaPlayer(MediaPlayerEntity): _attr_media_content_type = MediaType.MUSIC _attr_should_poll = False + _attr_supported_features = BASE_SUPPORTED_FEATURES + _attr_media_image_remotely_accessible = True _attr_has_entity_name = True _attr_name = None @@ -122,9 +124,16 @@ def __init__(self, player): self._media_position_updated_at = None self._player = player self._signals = [] - self._attr_supported_features = BASE_SUPPORTED_FEATURES self._source_manager = None self._group_manager = None + self._attr_unique_id = str(player.player_id) + self._attr_device_info = DeviceInfo( + identifiers={(HEOS_DOMAIN, player.player_id)}, + manufacturer="HEOS", + model=player.model, + name=player.name, + sw_version=player.version, + ) async def _player_update(self, player_id, event): """Handle player attribute updated.""" @@ -306,17 +315,6 @@ def available(self) -> bool: """Return True if the device is available.""" return self._player.available - @property - def device_info(self) -> DeviceInfo: - """Get attributes about the device.""" - return DeviceInfo( - identifiers={(HEOS_DOMAIN, self._player.player_id)}, - manufacturer="HEOS", - model=self._player.model, - name=self._player.name, - sw_version=self._player.version, - ) - @property def extra_state_attributes(self) -> dict[str, Any]: """Get additional attribute about the state.""" @@ -377,11 +375,6 @@ def media_position_updated_at(self): return None return self._media_position_updated_at - @property - def media_image_remotely_accessible(self) -> bool: - """If the image url is remotely accessible.""" - return True - @property def media_image_url(self) -> str: """Image url of current playing media.""" @@ -414,11 +407,6 @@ def state(self) -> MediaPlayerState: """State of the player.""" return PLAY_STATE_TO_STATE[self._player.state] - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return str(self._player.player_id) - @property def volume_level(self) -> float: """Volume level of the media player (0..1).""" diff --git a/homeassistant/components/hisense_aehw4a1/climate.py b/homeassistant/components/hisense_aehw4a1/climate.py index 113a0c622b9909..ca5ec694eab7c8 100644 --- a/homeassistant/components/hisense_aehw4a1/climate.py +++ b/homeassistant/components/hisense_aehw4a1/climate.py @@ -145,23 +145,19 @@ class ClimateAehW4a1(ClimateEntity): | ClimateEntityFeature.SWING_MODE | ClimateEntityFeature.PRESET_MODE ) + _attr_fan_modes = FAN_MODES + _attr_swing_modes = SWING_MODES + _attr_preset_modes = PRESET_MODES + _attr_available = False + _attr_target_temperature_step = 1 + _previous_state: HVACMode | str | None = None + _on: str | None = None def __init__(self, device): """Initialize the climate device.""" - self._unique_id = device + self._attr_unique_id = device + self._attr_name = device self._device = AehW4a1(device) - self._fan_modes = FAN_MODES - self._swing_modes = SWING_MODES - self._preset_modes = PRESET_MODES - self._attr_available = False - self._on = None - self._current_temperature = None - self._target_temperature = None - self._attr_hvac_mode = None - self._fan_mode = None - self._swing_mode = None - self._preset_mode = None - self._previous_state = None async def async_update(self) -> None: """Pull state from AEH-W4A1.""" @@ -169,7 +165,7 @@ async def async_update(self) -> None: status = await self._device.command("status_102_0") except pyaehw4a1.exceptions.ConnectionError as library_error: _LOGGER.warning( - "Unexpected error of %s: %s", self._unique_id, library_error + "Unexpected error of %s: %s", self._attr_unique_id, library_error ) self._attr_available = False return @@ -180,123 +176,65 @@ async def async_update(self) -> None: if status["temperature_Fahrenheit"] == "0": self._attr_temperature_unit = UnitOfTemperature.CELSIUS + self._attr_min_temp = MIN_TEMP_C + self._attr_max_temp = MAX_TEMP_C else: self._attr_temperature_unit = UnitOfTemperature.FAHRENHEIT + self._attr_min_temp = MIN_TEMP_F + self._attr_max_temp = MAX_TEMP_F - self._current_temperature = int(status["indoor_temperature_status"], 2) + self._attr_current_temperature = int(status["indoor_temperature_status"], 2) if self._on == "1": device_mode = status["mode_status"] self._attr_hvac_mode = AC_TO_HA_STATE[device_mode] fan_mode = status["wind_status"] - self._fan_mode = AC_TO_HA_FAN_MODES[fan_mode] + self._attr_fan_mode = AC_TO_HA_FAN_MODES[fan_mode] swing_mode = f'{status["up_down"]}{status["left_right"]}' - self._swing_mode = AC_TO_HA_SWING[swing_mode] + self._attr_swing_mode = AC_TO_HA_SWING[swing_mode] if self._attr_hvac_mode in (HVACMode.COOL, HVACMode.HEAT): - self._target_temperature = int(status["indoor_temperature_setting"], 2) + self._attr_target_temperature = int( + status["indoor_temperature_setting"], 2 + ) else: - self._target_temperature = None + self._attr_target_temperature = None if status["efficient"] == "1": - self._preset_mode = PRESET_BOOST + self._attr_preset_mode = PRESET_BOOST elif status["low_electricity"] == "1": - self._preset_mode = PRESET_ECO + self._attr_preset_mode = PRESET_ECO elif status["sleep_status"] == "0000001": - self._preset_mode = PRESET_SLEEP + self._attr_preset_mode = PRESET_SLEEP elif status["sleep_status"] == "0000010": - self._preset_mode = "sleep_2" + self._attr_preset_mode = "sleep_2" elif status["sleep_status"] == "0000011": - self._preset_mode = "sleep_3" + self._attr_preset_mode = "sleep_3" elif status["sleep_status"] == "0000100": - self._preset_mode = "sleep_4" + self._attr_preset_mode = "sleep_4" else: - self._preset_mode = PRESET_NONE + self._attr_preset_mode = PRESET_NONE else: self._attr_hvac_mode = HVACMode.OFF - self._fan_mode = None - self._swing_mode = None - self._target_temperature = None - self._preset_mode = None - - @property - def name(self): - """Return the name of the climate device.""" - return self._unique_id - - @property - def current_temperature(self): - """Return the current temperature.""" - return self._current_temperature - - @property - def target_temperature(self): - """Return the temperature we are trying to reach.""" - return self._target_temperature - - @property - def fan_mode(self): - """Return the fan setting.""" - return self._fan_mode - - @property - def fan_modes(self): - """Return the list of available fan modes.""" - return self._fan_modes - - @property - def preset_mode(self): - """Return the preset mode if on.""" - return self._preset_mode - - @property - def preset_modes(self): - """Return the list of available preset modes.""" - return self._preset_modes - - @property - def swing_mode(self): - """Return swing operation.""" - return self._swing_mode - - @property - def swing_modes(self): - """Return the list of available fan modes.""" - return self._swing_modes - - @property - def min_temp(self): - """Return the minimum temperature.""" - if self.temperature_unit == UnitOfTemperature.CELSIUS: - return MIN_TEMP_C - return MIN_TEMP_F - - @property - def max_temp(self): - """Return the maximum temperature.""" - if self.temperature_unit == UnitOfTemperature.CELSIUS: - return MAX_TEMP_C - return MAX_TEMP_F - - @property - def target_temperature_step(self): - """Return the supported step of target temperature.""" - return 1 + self._attr_fan_mode = None + self._attr_swing_mode = None + self._attr_target_temperature = None + self._attr_preset_mode = None async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperatures.""" if self._on != "1": _LOGGER.warning( - "AC at %s is off, could not set temperature", self._unique_id + "AC at %s is off, could not set temperature", self._attr_unique_id ) return if (temp := kwargs.get(ATTR_TEMPERATURE)) is not None: - _LOGGER.debug("Setting temp of %s to %s", self._unique_id, temp) - if self._preset_mode != PRESET_NONE: + _LOGGER.debug("Setting temp of %s to %s", self._attr_unique_id, temp) + if self._attr_preset_mode != PRESET_NONE: await self.async_set_preset_mode(PRESET_NONE) - if self.temperature_unit == UnitOfTemperature.CELSIUS: + if self._attr_temperature_unit == UnitOfTemperature.CELSIUS: await self._device.command(f"temp_{int(temp)}_C") else: await self._device.command(f"temp_{int(temp)}_F") @@ -304,24 +242,30 @@ async def async_set_temperature(self, **kwargs: Any) -> None: async def async_set_fan_mode(self, fan_mode: str) -> None: """Set new fan mode.""" if self._on != "1": - _LOGGER.warning("AC at %s is off, could not set fan mode", self._unique_id) + _LOGGER.warning( + "AC at %s is off, could not set fan mode", self._attr_unique_id + ) return if self._attr_hvac_mode in (HVACMode.COOL, HVACMode.FAN_ONLY) and ( self._attr_hvac_mode != HVACMode.FAN_ONLY or fan_mode != FAN_AUTO ): - _LOGGER.debug("Setting fan mode of %s to %s", self._unique_id, fan_mode) + _LOGGER.debug( + "Setting fan mode of %s to %s", self._attr_unique_id, fan_mode + ) await self._device.command(HA_FAN_MODES_TO_AC[fan_mode]) async def async_set_swing_mode(self, swing_mode: str) -> None: """Set new target swing operation.""" if self._on != "1": _LOGGER.warning( - "AC at %s is off, could not set swing mode", self._unique_id + "AC at %s is off, could not set swing mode", self._attr_unique_id ) return - _LOGGER.debug("Setting swing mode of %s to %s", self._unique_id, swing_mode) - swing_act = self._swing_mode + _LOGGER.debug( + "Setting swing mode of %s to %s", self._attr_unique_id, swing_mode + ) + swing_act = self._attr_swing_mode if swing_mode == SWING_OFF and swing_act != SWING_OFF: if swing_act in (SWING_HORIZONTAL, SWING_BOTH): @@ -354,7 +298,9 @@ async def async_set_preset_mode(self, preset_mode: str) -> None: return await self.async_turn_on() - _LOGGER.debug("Setting preset mode of %s to %s", self._unique_id, preset_mode) + _LOGGER.debug( + "Setting preset mode of %s to %s", self._attr_unique_id, preset_mode + ) if preset_mode == PRESET_ECO: await self._device.command("energysave_on") @@ -379,13 +325,17 @@ async def async_set_preset_mode(self, preset_mode: str) -> None: await self._device.command("energysave_off") elif self._previous_state == PRESET_BOOST: await self._device.command("turbo_off") - elif self._previous_state in HA_STATE_TO_AC: + elif self._previous_state in HA_STATE_TO_AC and isinstance( + self._previous_state, HVACMode + ): await self._device.command(HA_STATE_TO_AC[self._previous_state]) self._previous_state = None async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new operation mode.""" - _LOGGER.debug("Setting operation mode of %s to %s", self._unique_id, hvac_mode) + _LOGGER.debug( + "Setting operation mode of %s to %s", self._attr_unique_id, hvac_mode + ) if hvac_mode == HVACMode.OFF: await self.async_turn_off() else: @@ -395,10 +345,10 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: async def async_turn_on(self) -> None: """Turn on.""" - _LOGGER.debug("Turning %s on", self._unique_id) + _LOGGER.debug("Turning %s on", self._attr_unique_id) await self._device.command("on") async def async_turn_off(self) -> None: """Turn off.""" - _LOGGER.debug("Turning %s off", self._unique_id) + _LOGGER.debug("Turning %s off", self._attr_unique_id) await self._device.command("off") diff --git a/homeassistant/components/hlk_sw16/__init__.py b/homeassistant/components/hlk_sw16/__init__.py index f80972da6130fb..9be0b5203fd904 100644 --- a/homeassistant/components/hlk_sw16/__init__.py +++ b/homeassistant/components/hlk_sw16/__init__.py @@ -147,12 +147,8 @@ def __init__(self, device_port, entry_id, client): self._device_port = device_port self._is_on = None self._client = client - self._name = device_port - - @property - def unique_id(self): - """Return a unique ID.""" - return f"{self._entry_id}_{self._device_port}" + self._attr_name = device_port + self._attr_unique_id = f"{self._entry_id}_{self._device_port}" @callback def handle_event_callback(self, event): @@ -161,11 +157,6 @@ def handle_event_callback(self, event): self._is_on = event self.async_write_ha_state() - @property - def name(self): - """Return a name for the device.""" - return self._name - @property def available(self): """Return True if entity is available.""" diff --git a/homeassistant/components/home_connect/entity.py b/homeassistant/components/home_connect/entity.py index 12fe7be3be99f0..d60f8a96e0982a 100644 --- a/homeassistant/components/home_connect/entity.py +++ b/homeassistant/components/home_connect/entity.py @@ -21,8 +21,14 @@ class HomeConnectEntity(Entity): def __init__(self, device: HomeConnectDevice, desc: str) -> None: """Initialize the entity.""" self.device = device - self.desc = desc - self._name = f"{self.device.appliance.name} {desc}" + self._attr_name = f"{device.appliance.name} {desc}" + self._attr_unique_id = f"{device.appliance.haId}-{desc}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device.appliance.haId)}, + manufacturer=device.appliance.brand, + model=device.appliance.vib, + name=device.appliance.name, + ) async def async_added_to_hass(self): """Register callbacks.""" @@ -38,26 +44,6 @@ def _update_callback(self, ha_id): if ha_id == self.device.appliance.haId: self.async_entity_update() - @property - def name(self): - """Return the name of the node (used for Entity_ID).""" - return self._name - - @property - def unique_id(self): - """Return the unique id base on the id returned by Home Connect and the entity name.""" - return f"{self.device.appliance.haId}-{self.desc}" - - @property - def device_info(self) -> DeviceInfo: - """Return info about the device.""" - return DeviceInfo( - identifiers={(DOMAIN, self.device.appliance.haId)}, - manufacturer=self.device.appliance.brand, - model=self.device.appliance.vib, - name=self.device.appliance.name, - ) - @callback def async_entity_update(self): """Update the entity.""" diff --git a/homeassistant/components/home_connect/light.py b/homeassistant/components/home_connect/light.py index 17dc842358f963..7e65fed034d08b 100644 --- a/homeassistant/components/home_connect/light.py +++ b/homeassistant/components/home_connect/light.py @@ -59,11 +59,8 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): def __init__(self, device, desc, ambient): """Initialize the entity.""" super().__init__(device, desc) - self._state = None - self._brightness = None - self._hs_color = None self._ambient = ambient - if self._ambient: + if ambient: self._brightness_key = BSH_AMBIENT_LIGHT_BRIGHTNESS self._key = BSH_AMBIENT_LIGHT_ENABLED self._custom_color_key = BSH_AMBIENT_LIGHT_CUSTOM_COLOR @@ -78,21 +75,6 @@ def __init__(self, device, desc, ambient): self._attr_color_mode = ColorMode.BRIGHTNESS self._attr_supported_color_modes = {ColorMode.BRIGHTNESS} - @property - def is_on(self): - """Return true if the light is on.""" - return bool(self._state) - - @property - def brightness(self): - """Return the brightness of the light.""" - return self._brightness - - @property - def hs_color(self): - """Return the color property.""" - return self._hs_color - async def async_turn_on(self, **kwargs: Any) -> None: """Switch the light on, change brightness, change color.""" if self._ambient: @@ -113,12 +95,12 @@ async def async_turn_on(self, **kwargs: Any) -> None: ) except HomeConnectError as err: _LOGGER.error("Error while trying selecting customcolor: %s", err) - if self._brightness is not None: - brightness = 10 + ceil(self._brightness / 255 * 90) + if self._attr_brightness is not None: + brightness = 10 + ceil(self._attr_brightness / 255 * 90) if ATTR_BRIGHTNESS in kwargs: brightness = 10 + ceil(kwargs[ATTR_BRIGHTNESS] / 255 * 90) - hs_color = kwargs.get(ATTR_HS_COLOR, self._hs_color) + hs_color = kwargs.get(ATTR_HS_COLOR, self._attr_hs_color) if hs_color is not None: rgb = color_util.color_hsv_to_RGB( @@ -170,32 +152,34 @@ async def async_turn_off(self, **kwargs: Any) -> None: async def async_update(self) -> None: """Update the light's status.""" if self.device.appliance.status.get(self._key, {}).get(ATTR_VALUE) is True: - self._state = True + self._attr_is_on = True elif self.device.appliance.status.get(self._key, {}).get(ATTR_VALUE) is False: - self._state = False + self._attr_is_on = False else: - self._state = None + self._attr_is_on = None - _LOGGER.debug("Updated, new light state: %s", self._state) + _LOGGER.debug("Updated, new light state: %s", self._attr_is_on) if self._ambient: color = self.device.appliance.status.get(self._custom_color_key, {}) if not color: - self._hs_color = None - self._brightness = None + self._attr_hs_color = None + self._attr_brightness = None else: colorvalue = color.get(ATTR_VALUE)[1:] rgb = color_util.rgb_hex_to_rgb_list(colorvalue) hsv = color_util.color_RGB_to_hsv(rgb[0], rgb[1], rgb[2]) - self._hs_color = [hsv[0], hsv[1]] - self._brightness = ceil((hsv[2] - 10) * 255 / 90) - _LOGGER.debug("Updated, new brightness: %s", self._brightness) + self._attr_hs_color = (hsv[0], hsv[1]) + self._attr_brightness = ceil((hsv[2] - 10) * 255 / 90) + _LOGGER.debug("Updated, new brightness: %s", self._attr_brightness) else: brightness = self.device.appliance.status.get(self._brightness_key, {}) if brightness is None: - self._brightness = None + self._attr_brightness = None else: - self._brightness = ceil((brightness.get(ATTR_VALUE) - 10) * 255 / 90) - _LOGGER.debug("Updated, new brightness: %s", self._brightness) + self._attr_brightness = ceil( + (brightness.get(ATTR_VALUE) - 10) * 255 / 90 + ) + _LOGGER.debug("Updated, new brightness: %s", self._attr_brightness) diff --git a/homeassistant/components/home_connect/sensor.py b/homeassistant/components/home_connect/sensor.py index efd2a9b34dd483..07edfb4bd4b040 100644 --- a/homeassistant/components/home_connect/sensor.py +++ b/homeassistant/components/home_connect/sensor.py @@ -1,6 +1,7 @@ """Provides a sensor for Home Connect.""" -from datetime import timedelta +from datetime import datetime, timedelta import logging +from typing import cast from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry @@ -40,62 +41,44 @@ class HomeConnectSensor(HomeConnectEntity, SensorEntity): def __init__(self, device, desc, key, unit, icon, device_class, sign=1): """Initialize the entity.""" super().__init__(device, desc) - self._state = None self._key = key - self._unit = unit - self._icon = icon - self._device_class = device_class self._sign = sign - - @property - def native_value(self): - """Return sensor value.""" - return self._state + self._attr_native_unit_of_measurement = unit + self._attr_icon = icon + self._attr_device_class = device_class @property def available(self) -> bool: """Return true if the sensor is available.""" - return self._state is not None + return self._attr_native_value is not None async def async_update(self) -> None: """Update the sensor's status.""" status = self.device.appliance.status if self._key not in status: - self._state = None + self._attr_native_value = None elif self.device_class == SensorDeviceClass.TIMESTAMP: if ATTR_VALUE not in status[self._key]: - self._state = None + self._attr_native_value = None elif ( - self._state is not None + self._attr_native_value is not None and self._sign == 1 - and self._state < dt_util.utcnow() + and isinstance(self._attr_native_value, datetime) + and self._attr_native_value < dt_util.utcnow() ): # if the date is supposed to be in the future but we're # already past it, set state to None. - self._state = None + self._attr_native_value = None else: seconds = self._sign * float(status[self._key][ATTR_VALUE]) - self._state = dt_util.utcnow() + timedelta(seconds=seconds) + self._attr_native_value = dt_util.utcnow() + timedelta(seconds=seconds) else: - self._state = status[self._key].get(ATTR_VALUE) + self._attr_native_value = status[self._key].get(ATTR_VALUE) if self._key == BSH_OPERATION_STATE: # Value comes back as an enum, we only really care about the # last part, so split it off # https://developer.home-connect.com/docs/status/operation_state - self._state = self._state.split(".")[-1] - _LOGGER.debug("Updated, new state: %s", self._state) - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement.""" - return self._unit - - @property - def icon(self): - """Return the icon.""" - return self._icon - - @property - def device_class(self): - """Return the device class.""" - return self._device_class + self._attr_native_value = cast(str, self._attr_native_value).split(".")[ + -1 + ] + _LOGGER.debug("Updated, new state: %s", self._attr_native_value) diff --git a/homeassistant/components/home_connect/switch.py b/homeassistant/components/home_connect/switch.py index 61dd11dbc6f248..dbcbfde9dc2e77 100644 --- a/homeassistant/components/home_connect/switch.py +++ b/homeassistant/components/home_connect/switch.py @@ -56,13 +56,6 @@ def __init__(self, device, program_name): ) super().__init__(device, desc) self.program_name = program_name - self._state = None - self._remote_allowed = None - - @property - def is_on(self): - """Return true if the switch is on.""" - return bool(self._state) async def async_turn_on(self, **kwargs: Any) -> None: """Start the program.""" @@ -88,10 +81,10 @@ async def async_update(self) -> None: """Update the switch's status.""" state = self.device.appliance.status.get(BSH_ACTIVE_PROGRAM, {}) if state.get(ATTR_VALUE) == self.program_name: - self._state = True + self._attr_is_on = True else: - self._state = False - _LOGGER.debug("Updated, new state: %s", self._state) + self._attr_is_on = False + _LOGGER.debug("Updated, new state: %s", self._attr_is_on) class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity): @@ -100,12 +93,6 @@ class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity): def __init__(self, device): """Inititialize the entity.""" super().__init__(device, "Power") - self._state = None - - @property - def is_on(self): - """Return true if the switch is on.""" - return bool(self._state) async def async_turn_on(self, **kwargs: Any) -> None: """Switch the device on.""" @@ -116,7 +103,7 @@ async def async_turn_on(self, **kwargs: Any) -> None: ) except HomeConnectError as err: _LOGGER.error("Error while trying to turn on device: %s", err) - self._state = False + self._attr_is_on = False self.async_entity_update() async def async_turn_off(self, **kwargs: Any) -> None: @@ -130,7 +117,7 @@ async def async_turn_off(self, **kwargs: Any) -> None: ) except HomeConnectError as err: _LOGGER.error("Error while trying to turn off device: %s", err) - self._state = True + self._attr_is_on = True self.async_entity_update() async def async_update(self) -> None: @@ -139,12 +126,12 @@ async def async_update(self) -> None: self.device.appliance.status.get(BSH_POWER_STATE, {}).get(ATTR_VALUE) == BSH_POWER_ON ): - self._state = True + self._attr_is_on = True elif ( self.device.appliance.status.get(BSH_POWER_STATE, {}).get(ATTR_VALUE) == self.device.power_off_state ): - self._state = False + self._attr_is_on = False elif self.device.appliance.status.get(BSH_OPERATION_STATE, {}).get( ATTR_VALUE, None ) in [ @@ -156,12 +143,12 @@ async def async_update(self) -> None: "BSH.Common.EnumType.OperationState.Aborting", "BSH.Common.EnumType.OperationState.Finished", ]: - self._state = True + self._attr_is_on = True elif ( self.device.appliance.status.get(BSH_OPERATION_STATE, {}).get(ATTR_VALUE) == "BSH.Common.EnumType.OperationState.Inactive" ): - self._state = False + self._attr_is_on = False else: - self._state = None - _LOGGER.debug("Updated, new state: %s", self._state) + self._attr_is_on = None + _LOGGER.debug("Updated, new state: %s", self._attr_is_on) diff --git a/homeassistant/components/homeassistant/__init__.py b/homeassistant/components/homeassistant/__init__.py index 987a4317ba84ac..e4032ad954d297 100644 --- a/homeassistant/components/homeassistant/__init__.py +++ b/homeassistant/components/homeassistant/__init__.py @@ -9,6 +9,7 @@ from homeassistant.components import persistent_notification import homeassistant.config as conf_util from homeassistant.const import ( + ATTR_ELEVATION, ATTR_ENTITY_ID, ATTR_LATITUDE, ATTR_LONGITUDE, @@ -250,16 +251,28 @@ async def async_handle_reload_config(call: ha.ServiceCall) -> None: async def async_set_location(call: ha.ServiceCall) -> None: """Service handler to set location.""" - await hass.config.async_update( - latitude=call.data[ATTR_LATITUDE], longitude=call.data[ATTR_LONGITUDE] - ) + service_data = { + "latitude": call.data[ATTR_LATITUDE], + "longitude": call.data[ATTR_LONGITUDE], + } + + if elevation := call.data.get(ATTR_ELEVATION): + service_data["elevation"] = elevation + + await hass.config.async_update(**service_data) async_register_admin_service( hass, ha.DOMAIN, SERVICE_SET_LOCATION, async_set_location, - vol.Schema({ATTR_LATITUDE: cv.latitude, ATTR_LONGITUDE: cv.longitude}), + vol.Schema( + { + vol.Required(ATTR_LATITUDE): cv.latitude, + vol.Required(ATTR_LONGITUDE): cv.longitude, + vol.Optional(ATTR_ELEVATION): int, + } + ), ) async def async_handle_reload_templates(call: ha.ServiceCall) -> None: diff --git a/homeassistant/components/homeassistant/services.yaml b/homeassistant/components/homeassistant/services.yaml index 899fee357fd527..2b5fd3fc6860cb 100644 --- a/homeassistant/components/homeassistant/services.yaml +++ b/homeassistant/components/homeassistant/services.yaml @@ -7,12 +7,27 @@ set_location: required: true example: 32.87336 selector: - text: + number: + mode: box + min: -90 + max: 90 + step: any longitude: required: true example: 117.22743 selector: - text: + number: + mode: box + min: -180 + max: 180 + step: any + elevation: + required: false + example: 120 + selector: + number: + mode: box + step: any stop: toggle: diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json index 5404ee4af6496e..53510a94f01953 100644 --- a/homeassistant/components/homeassistant/strings.json +++ b/homeassistant/components/homeassistant/strings.json @@ -81,6 +81,10 @@ "longitude": { "name": "[%key:common::config_flow::data::longitude%]", "description": "Longitude of your location." + }, + "elevation": { + "name": "[%key:common::config_flow::data::elevation%]", + "description": "Elevation of your location." } } }, diff --git a/homeassistant/components/homeassistant_sky_connect/strings.json b/homeassistant/components/homeassistant_sky_connect/strings.json index 58fc0180743306..2ed0026a48ce43 100644 --- a/homeassistant/components/homeassistant_sky_connect/strings.json +++ b/homeassistant/components/homeassistant_sky_connect/strings.json @@ -60,7 +60,7 @@ } }, "error": { - "unknown": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::error::unknown%]" + "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { "addon_info_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_info_failed%]", diff --git a/homeassistant/components/homeassistant_yellow/strings.json b/homeassistant/components/homeassistant_yellow/strings.json index e5250f163cec4a..894d799d0735e0 100644 --- a/homeassistant/components/homeassistant_yellow/strings.json +++ b/homeassistant/components/homeassistant_yellow/strings.json @@ -27,9 +27,9 @@ "hardware_settings": { "title": "Configure hardware settings", "data": { - "disk_led": "Disk LED", - "heartbeat_led": "Heartbeat LED", - "power_led": "Power LED" + "heartbeat_led": "Yellow: system health LED", + "disk_led": "Green: activity LED", + "power_led": "Red: power LED" } }, "install_addon": { @@ -82,7 +82,7 @@ } }, "error": { - "unknown": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::error::unknown%]" + "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { "addon_info_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_info_failed%]", diff --git a/homeassistant/components/homekit/config_flow.py b/homeassistant/components/homekit/config_flow.py index 3747af3edc7d68..c43093d92b49b6 100644 --- a/homeassistant/components/homekit/config_flow.py +++ b/homeassistant/components/homekit/config_flow.py @@ -3,6 +3,7 @@ from collections.abc import Iterable from copy import deepcopy +from operator import itemgetter import random import re import string @@ -638,7 +639,7 @@ async def _async_get_supported_devices(hass: HomeAssistant) -> dict[str, str]: for device_id in results: entry = dev_reg.async_get(device_id) unsorted[device_id] = entry.name or device_id if entry else device_id - return dict(sorted(unsorted.items(), key=lambda item: item[1])) + return dict(sorted(unsorted.items(), key=itemgetter(1))) def _exclude_by_entity_registry( diff --git a/homeassistant/components/homekit/type_cameras.py b/homeassistant/components/homekit/type_cameras.py index 62d27245a1c951..4c7ba5a7841e92 100644 --- a/homeassistant/components/homekit/type_cameras.py +++ b/homeassistant/components/homekit/type_cameras.py @@ -79,7 +79,7 @@ "-ssrc {v_ssrc} -f rtp " "-srtp_out_suite AES_CM_128_HMAC_SHA1_80 -srtp_out_params {v_srtp_key} " "srtp://{address}:{v_port}?rtcpport={v_port}&" - "localrtcpport={v_port}&pkt_size={v_pkt_size}" + "localrtpport={v_port}&pkt_size={v_pkt_size}" ) AUDIO_OUTPUT = ( @@ -92,7 +92,7 @@ "-ssrc {a_ssrc} -f rtp " "-srtp_out_suite AES_CM_128_HMAC_SHA1_80 -srtp_out_params {a_srtp_key} " "srtp://{address}:{a_port}?rtcpport={a_port}&" - "localrtcpport={a_port}&pkt_size={a_pkt_size}" + "localrtpport={a_port}&pkt_size={a_pkt_size}" ) SLOW_RESOLUTIONS = [ diff --git a/homeassistant/components/homekit_controller/config_flow.py b/homeassistant/components/homekit_controller/config_flow.py index 988adbd87a7b86..088747d39ffc65 100644 --- a/homeassistant/components/homekit_controller/config_flow.py +++ b/homeassistant/components/homekit_controller/config_flow.py @@ -80,12 +80,12 @@ def formatted_category(category: Categories) -> str: @callback -def find_existing_host( - hass: HomeAssistant, serial: str +def find_existing_config_entry( + hass: HomeAssistant, upper_case_hkid: str ) -> config_entries.ConfigEntry | None: """Return a set of the configured hosts.""" for entry in hass.config_entries.async_entries(DOMAIN): - if entry.data.get("AccessoryPairingID") == serial: + if entry.data.get("AccessoryPairingID") == upper_case_hkid: return entry return None @@ -114,7 +114,7 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize the homekit_controller flow.""" self.model: str | None = None - self.hkid: str | None = None + self.hkid: str | None = None # This is always lower case self.name: str | None = None self.category: Categories | None = None self.devices: dict[str, AbstractDiscovery] = {} @@ -199,11 +199,12 @@ async def async_step_unignore(self, user_input: dict[str, Any]) -> FlowResult: return self._async_step_pair_show_form() - async def _hkid_is_homekit(self, hkid: str) -> bool: + @callback + def _hkid_is_homekit(self, hkid: str) -> bool: """Determine if the device is a homekit bridge or accessory.""" dev_reg = dr.async_get(self.hass) device = dev_reg.async_get_device( - connections={(dr.CONNECTION_NETWORK_MAC, hkid)} + connections={(dr.CONNECTION_NETWORK_MAC, dr.format_mac(hkid))} ) if device is None: @@ -244,17 +245,10 @@ async def async_step_zeroconf( # The hkid is a unique random number that looks like a pairing code. # It changes if a device is factory reset. - hkid = properties[zeroconf.ATTR_PROPERTIES_ID] + hkid: str = properties[zeroconf.ATTR_PROPERTIES_ID] normalized_hkid = normalize_hkid(hkid) - - # If this aiohomekit doesn't support this particular device, ignore it. - if not domain_supported(discovery_info.name): - return self.async_abort(reason="ignored_model") - - model = properties["md"] - name = domain_to_name(discovery_info.name) + upper_case_hkid = hkid.upper() status_flags = int(properties["sf"]) - category = Categories(int(properties.get("ci", 0))) paired = not status_flags & 0x01 # Set unique-id and error out if it's already configured @@ -265,23 +259,29 @@ async def async_step_zeroconf( "AccessoryIP": discovery_info.host, "AccessoryPort": discovery_info.port, } - # If the device is already paired and known to us we should monitor c# # (config_num) for changes. If it changes, we check for new entities - if paired and hkid in self.hass.data.get(KNOWN_DEVICES, {}): + if paired and upper_case_hkid in self.hass.data.get(KNOWN_DEVICES, {}): if existing_entry: self.hass.config_entries.async_update_entry( existing_entry, data={**existing_entry.data, **updated_ip_port} ) return self.async_abort(reason="already_configured") - _LOGGER.debug("Discovered device %s (%s - %s)", name, model, hkid) + # If this aiohomekit doesn't support this particular device, ignore it. + if not domain_supported(discovery_info.name): + return self.async_abort(reason="ignored_model") + + model = properties["md"] + name = domain_to_name(discovery_info.name) + _LOGGER.debug("Discovered device %s (%s - %s)", name, model, upper_case_hkid) # Device isn't paired with us or anyone else. # But we have a 'complete' config entry for it - that is probably # invalid. Remove it automatically. - existing = find_existing_host(self.hass, hkid) - if not paired and existing: + if not paired and ( + existing := find_existing_config_entry(self.hass, upper_case_hkid) + ): if self.controller is None: await self._async_setup_controller() @@ -348,13 +348,13 @@ async def async_step_zeroconf( # If this is a HomeKit bridge/accessory exported # by *this* HA instance ignore it. - if await self._hkid_is_homekit(hkid): + if self._hkid_is_homekit(hkid): return self.async_abort(reason="ignored_model") self.name = name self.model = model - self.category = category - self.hkid = hkid + self.category = Categories(int(properties.get("ci", 0))) + self.hkid = normalized_hkid # We want to show the pairing form - but don't call async_step_pair # directly as it has side effects (will ask the device to show a diff --git a/homeassistant/components/homekit_controller/connection.py b/homeassistant/components/homekit_controller/connection.py index 3e5fd4655d667e..348dd5e7ccfe06 100644 --- a/homeassistant/components/homekit_controller/connection.py +++ b/homeassistant/components/homekit_controller/connection.py @@ -5,6 +5,7 @@ from collections.abc import Callable, Iterable from datetime import datetime, timedelta import logging +from operator import attrgetter from types import MappingProxyType from typing import Any @@ -508,9 +509,7 @@ def async_create_devices(self) -> None: # Accessories need to be created in the correct order or setting up # relationships with ATTR_VIA_DEVICE may fail. - for accessory in sorted( - self.entity_map.accessories, key=lambda accessory: accessory.aid - ): + for accessory in sorted(self.entity_map.accessories, key=attrgetter("aid")): device_info = self.device_info_for_accessory(accessory) device = device_registry.async_get_or_create( diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index 83852f38d523d0..c99142da475208 100644 --- a/homeassistant/components/homekit_controller/manifest.json +++ b/homeassistant/components/homekit_controller/manifest.json @@ -14,6 +14,6 @@ "documentation": "https://www.home-assistant.io/integrations/homekit_controller", "iot_class": "local_push", "loggers": ["aiohomekit", "commentjson"], - "requirements": ["aiohomekit==3.0.1"], + "requirements": ["aiohomekit==3.0.3"], "zeroconf": ["_hap._tcp.local.", "_hap._udp.local."] } diff --git a/homeassistant/components/homematicip_cloud/binary_sensor.py b/homeassistant/components/homematicip_cloud/binary_sensor.py index 6730f722685348..2afe803e1ebd11 100644 --- a/homeassistant/components/homematicip_cloud/binary_sensor.py +++ b/homeassistant/components/homematicip_cloud/binary_sensor.py @@ -194,10 +194,7 @@ def available(self) -> bool: class HomematicipBaseActionSensor(HomematicipGenericEntity, BinarySensorEntity): """Representation of the HomematicIP base action sensor.""" - @property - def device_class(self) -> BinarySensorDeviceClass: - """Return the class of this sensor.""" - return BinarySensorDeviceClass.MOVING + _attr_device_class = BinarySensorDeviceClass.MOVING @property def is_on(self) -> bool: @@ -227,6 +224,8 @@ class HomematicipTiltVibrationSensor(HomematicipBaseActionSensor): class HomematicipMultiContactInterface(HomematicipGenericEntity, BinarySensorEntity): """Representation of the HomematicIP multi room/area contact interface.""" + _attr_device_class = BinarySensorDeviceClass.OPENING + def __init__( self, hap: HomematicipHAP, @@ -239,11 +238,6 @@ def __init__( hap, device, channel=channel, is_multi_channel=is_multi_channel ) - @property - def device_class(self) -> BinarySensorDeviceClass: - """Return the class of this sensor.""" - return BinarySensorDeviceClass.OPENING - @property def is_on(self) -> bool | None: """Return true if the contact interface is on/open.""" @@ -266,6 +260,8 @@ def __init__(self, hap: HomematicipHAP, device) -> None: class HomematicipShutterContact(HomematicipMultiContactInterface, BinarySensorEntity): """Representation of the HomematicIP shutter contact.""" + _attr_device_class = BinarySensorDeviceClass.DOOR + def __init__( self, hap: HomematicipHAP, device, has_additional_state: bool = False ) -> None: @@ -273,11 +269,6 @@ def __init__( super().__init__(hap, device, is_multi_channel=False) self.has_additional_state = has_additional_state - @property - def device_class(self) -> BinarySensorDeviceClass: - """Return the class of this sensor.""" - return BinarySensorDeviceClass.DOOR - @property def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes of the Shutter Contact.""" @@ -294,10 +285,7 @@ def extra_state_attributes(self) -> dict[str, Any]: class HomematicipMotionDetector(HomematicipGenericEntity, BinarySensorEntity): """Representation of the HomematicIP motion detector.""" - @property - def device_class(self) -> BinarySensorDeviceClass: - """Return the class of this sensor.""" - return BinarySensorDeviceClass.MOTION + _attr_device_class = BinarySensorDeviceClass.MOTION @property def is_on(self) -> bool: @@ -308,10 +296,7 @@ def is_on(self) -> bool: class HomematicipPresenceDetector(HomematicipGenericEntity, BinarySensorEntity): """Representation of the HomematicIP presence detector.""" - @property - def device_class(self) -> BinarySensorDeviceClass: - """Return the class of this sensor.""" - return BinarySensorDeviceClass.PRESENCE + _attr_device_class = BinarySensorDeviceClass.PRESENCE @property def is_on(self) -> bool: @@ -322,10 +307,7 @@ def is_on(self) -> bool: class HomematicipSmokeDetector(HomematicipGenericEntity, BinarySensorEntity): """Representation of the HomematicIP smoke detector.""" - @property - def device_class(self) -> BinarySensorDeviceClass: - """Return the class of this sensor.""" - return BinarySensorDeviceClass.SMOKE + _attr_device_class = BinarySensorDeviceClass.SMOKE @property def is_on(self) -> bool: @@ -341,10 +323,7 @@ def is_on(self) -> bool: class HomematicipWaterDetector(HomematicipGenericEntity, BinarySensorEntity): """Representation of the HomematicIP water detector.""" - @property - def device_class(self) -> BinarySensorDeviceClass: - """Return the class of this sensor.""" - return BinarySensorDeviceClass.MOISTURE + _attr_device_class = BinarySensorDeviceClass.MOISTURE @property def is_on(self) -> bool: @@ -373,15 +352,12 @@ def is_on(self) -> bool: class HomematicipRainSensor(HomematicipGenericEntity, BinarySensorEntity): """Representation of the HomematicIP rain sensor.""" + _attr_device_class = BinarySensorDeviceClass.MOISTURE + def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize rain sensor.""" super().__init__(hap, device, "Raining") - @property - def device_class(self) -> BinarySensorDeviceClass: - """Return the class of this sensor.""" - return BinarySensorDeviceClass.MOISTURE - @property def is_on(self) -> bool: """Return true, if it is raining.""" @@ -391,15 +367,12 @@ def is_on(self) -> bool: class HomematicipSunshineSensor(HomematicipGenericEntity, BinarySensorEntity): """Representation of the HomematicIP sunshine sensor.""" + _attr_device_class = BinarySensorDeviceClass.LIGHT + def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize sunshine sensor.""" super().__init__(hap, device, post="Sunshine") - @property - def device_class(self) -> BinarySensorDeviceClass: - """Return the class of this sensor.""" - return BinarySensorDeviceClass.LIGHT - @property def is_on(self) -> bool: """Return true if sun is shining.""" @@ -420,15 +393,12 @@ def extra_state_attributes(self) -> dict[str, Any]: class HomematicipBatterySensor(HomematicipGenericEntity, BinarySensorEntity): """Representation of the HomematicIP low battery sensor.""" + _attr_device_class = BinarySensorDeviceClass.BATTERY + def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize battery sensor.""" super().__init__(hap, device, post="Battery") - @property - def device_class(self) -> BinarySensorDeviceClass: - """Return the class of this sensor.""" - return BinarySensorDeviceClass.BATTERY - @property def is_on(self) -> bool: """Return true if battery is low.""" @@ -440,15 +410,12 @@ class HomematicipPluggableMainsFailureSurveillanceSensor( ): """Representation of the HomematicIP pluggable mains failure surveillance sensor.""" + _attr_device_class = BinarySensorDeviceClass.POWER + def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize pluggable mains failure surveillance sensor.""" super().__init__(hap, device) - @property - def device_class(self) -> BinarySensorDeviceClass: - """Return the class of this sensor.""" - return BinarySensorDeviceClass.POWER - @property def is_on(self) -> bool: """Return true if power mains fails.""" @@ -458,16 +425,13 @@ def is_on(self) -> bool: class HomematicipSecurityZoneSensorGroup(HomematicipGenericEntity, BinarySensorEntity): """Representation of the HomematicIP security zone sensor group.""" + _attr_device_class = BinarySensorDeviceClass.SAFETY + def __init__(self, hap: HomematicipHAP, device, post: str = "SecurityZone") -> None: """Initialize security zone group.""" device.modelType = f"HmIP-{post}" super().__init__(hap, device, post=post) - @property - def device_class(self) -> BinarySensorDeviceClass: - """Return the class of this sensor.""" - return BinarySensorDeviceClass.SAFETY - @property def available(self) -> bool: """Security-Group available.""" diff --git a/homeassistant/components/homematicip_cloud/cover.py b/homeassistant/components/homematicip_cloud/cover.py index e5007b5a15f02b..f5a9919579cabf 100644 --- a/homeassistant/components/homematicip_cloud/cover.py +++ b/homeassistant/components/homematicip_cloud/cover.py @@ -68,10 +68,7 @@ async def async_setup_entry( class HomematicipBlindModule(HomematicipGenericEntity, CoverEntity): """Representation of the HomematicIP blind module.""" - @property - def device_class(self) -> CoverDeviceClass: - """Return the class of the cover.""" - return CoverDeviceClass.BLIND + _attr_device_class = CoverDeviceClass.BLIND @property def current_cover_position(self) -> int | None: @@ -149,6 +146,8 @@ async def async_stop_cover_tilt(self, **kwargs: Any) -> None: class HomematicipMultiCoverShutter(HomematicipGenericEntity, CoverEntity): """Representation of the HomematicIP cover shutter.""" + _attr_device_class = CoverDeviceClass.SHUTTER + def __init__( self, hap: HomematicipHAP, @@ -161,11 +160,6 @@ def __init__( hap, device, channel=channel, is_multi_channel=is_multi_channel ) - @property - def device_class(self) -> CoverDeviceClass: - """Return the class of the cover.""" - return CoverDeviceClass.SHUTTER - @property def current_cover_position(self) -> int | None: """Return current position of cover.""" @@ -272,6 +266,8 @@ def __init__(self, hap: HomematicipHAP, device) -> None: class HomematicipGarageDoorModule(HomematicipGenericEntity, CoverEntity): """Representation of the HomematicIP Garage Door Module.""" + _attr_device_class = CoverDeviceClass.GARAGE + @property def current_cover_position(self) -> int | None: """Return current position of cover.""" @@ -283,11 +279,6 @@ def current_cover_position(self) -> int | None: } return door_state_to_position.get(self._device.doorState) - @property - def device_class(self) -> CoverDeviceClass: - """Return the class of the cover.""" - return CoverDeviceClass.GARAGE - @property def is_closed(self) -> bool | None: """Return if the cover is closed.""" @@ -309,16 +300,13 @@ async def async_stop_cover(self, **kwargs: Any) -> None: class HomematicipCoverShutterGroup(HomematicipGenericEntity, CoverEntity): """Representation of the HomematicIP cover shutter group.""" + _attr_device_class = CoverDeviceClass.SHUTTER + def __init__(self, hap: HomematicipHAP, device, post: str = "ShutterGroup") -> None: """Initialize switching group.""" device.modelType = f"HmIP-{post}" super().__init__(hap, device, post, is_multi_channel=False) - @property - def device_class(self) -> CoverDeviceClass: - """Return the class of the cover.""" - return CoverDeviceClass.SHUTTER - @property def current_cover_position(self) -> int | None: """Return current position of cover.""" diff --git a/homeassistant/components/homematicip_cloud/manifest.json b/homeassistant/components/homematicip_cloud/manifest.json index 1b86e36b826799..c3d14b7d38314a 100644 --- a/homeassistant/components/homematicip_cloud/manifest.json +++ b/homeassistant/components/homematicip_cloud/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_push", "loggers": ["homematicip"], "quality_scale": "silver", - "requirements": ["homematicip==1.0.14"] + "requirements": ["homematicip==1.0.15"] } diff --git a/homeassistant/components/homematicip_cloud/weather.py b/homeassistant/components/homematicip_cloud/weather.py index e913e1125f162c..573f291d55734b 100644 --- a/homeassistant/components/homematicip_cloud/weather.py +++ b/homeassistant/components/homematicip_cloud/weather.py @@ -72,6 +72,7 @@ class HomematicipWeatherSensor(HomematicipGenericEntity, WeatherEntity): _attr_native_temperature_unit = UnitOfTemperature.CELSIUS _attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR + _attr_attribution = "Powered by Homematic IP" def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the weather sensor.""" @@ -97,11 +98,6 @@ def native_wind_speed(self) -> float: """Return the wind speed.""" return self._device.windSpeed - @property - def attribution(self) -> str: - """Return the attribution.""" - return "Powered by Homematic IP" - @property def condition(self) -> str: """Return the current condition.""" @@ -128,6 +124,7 @@ class HomematicipHomeWeather(HomematicipGenericEntity, WeatherEntity): _attr_native_temperature_unit = UnitOfTemperature.CELSIUS _attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR + _attr_attribution = "Powered by Homematic IP" def __init__(self, hap: HomematicipHAP) -> None: """Initialize the home weather.""" @@ -164,11 +161,6 @@ def wind_bearing(self) -> float: """Return the wind bearing.""" return self._device.weather.windDirection - @property - def attribution(self) -> str: - """Return the attribution.""" - return "Powered by Homematic IP" - @property def condition(self) -> str | None: """Return the current condition.""" diff --git a/homeassistant/components/homewizard/manifest.json b/homeassistant/components/homewizard/manifest.json index 36b9631c801b32..8930ec90ebff3e 100644 --- a/homeassistant/components/homewizard/manifest.json +++ b/homeassistant/components/homewizard/manifest.json @@ -7,6 +7,6 @@ "iot_class": "local_polling", "loggers": ["homewizard_energy"], "quality_scale": "platinum", - "requirements": ["python-homewizard-energy==2.0.2"], + "requirements": ["python-homewizard-energy==2.1.0"], "zeroconf": ["_hwenergy._tcp.local."] } diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py index b23df9f1f4b458..63d05135d5df9c 100644 --- a/homeassistant/components/honeywell/climate.py +++ b/homeassistant/components/honeywell/climate.py @@ -27,6 +27,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -96,6 +98,42 @@ async def async_setup_entry( for device in data.devices.values() ] ) + remove_stale_devices(hass, entry, data.devices) + + +def remove_stale_devices( + hass: HomeAssistant, + config_entry: ConfigEntry, + devices: dict[str, SomeComfortDevice], +) -> None: + """Remove stale devices from device registry.""" + device_registry = dr.async_get(hass) + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + all_device_ids: set = set() + for device in devices.values(): + all_device_ids.add(device.deviceid) + + for device_entry in device_entries: + device_id: str | None = None + remove = True + + for identifier in device_entry.identifiers: + if identifier[0] != DOMAIN: + remove = False + continue + + device_id = identifier[1] + break + + if remove and (device_id is None or device_id not in all_device_ids): + # If device_id is None an invalid device entry was found for this config entry. + # If the device_id is not in existing device ids it's a stale device entry. + # Remove config entry from this device entry in either case. + device_registry.async_update_device( + device_entry.id, remove_config_entry_id=config_entry.entry_id + ) class HoneywellUSThermostat(ClimateEntity): @@ -315,6 +353,9 @@ async def _set_temperature(self, **kwargs) -> None: except SomeComfortError as err: _LOGGER.error("Invalid temperature %.1f: %s", temperature, err) + raise ValueError( + f"Honeywell set temperature failed: invalid temperature {temperature}." + ) from err async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" @@ -328,14 +369,23 @@ async def async_set_temperature(self, **kwargs: Any) -> None: except SomeComfortError as err: _LOGGER.error("Invalid temperature %.1f: %s", temperature, err) + raise ValueError( + f"Honeywell set temperature failed: invalid temperature: {temperature}." + ) from err async def async_set_fan_mode(self, fan_mode: str) -> None: """Set new target fan mode.""" - await self._device.set_fan_mode(self._fan_mode_map[fan_mode]) + try: + await self._device.set_fan_mode(self._fan_mode_map[fan_mode]) + except SomeComfortError as err: + raise HomeAssistantError("Honeywell could not set fan mode.") from err async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" - await self._device.set_system_mode(self._hvac_mode_map[hvac_mode]) + try: + await self._device.set_system_mode(self._hvac_mode_map[hvac_mode]) + except SomeComfortError as err: + raise HomeAssistantError("Honeywell could not set system mode.") from err async def _turn_away_mode_on(self) -> None: """Turn away on. @@ -355,13 +405,16 @@ async def _turn_away_mode_on(self) -> None: if mode in HEATING_MODES: await self._device.set_hold_heat(True, self._heat_away_temp) - except SomeComfortError: + except SomeComfortError as err: _LOGGER.error( "Temperature out of range. Mode: %s, Heat Temperature: %.1f, Cool Temperature: %.1f", mode, self._heat_away_temp, self._cool_away_temp, ) + raise ValueError( + f"Honeywell set temperature failed: temperature out of range. Mode: {mode}, Heat Temperuature: {self._heat_away_temp}, Cool Temperature: {self._cool_away_temp}." + ) from err async def _turn_hold_mode_on(self) -> None: """Turn permanent hold on.""" @@ -376,10 +429,14 @@ async def _turn_hold_mode_on(self) -> None: if mode in HEATING_MODES: await self._device.set_hold_heat(True) - except SomeComfortError: + except SomeComfortError as err: _LOGGER.error("Couldn't set permanent hold") + raise HomeAssistantError( + "Honeywell couldn't set permanent hold." + ) from err else: _LOGGER.error("Invalid system mode returned: %s", mode) + raise HomeAssistantError(f"Honeywell invalid system mode returned {mode}.") async def _turn_away_mode_off(self) -> None: """Turn away/hold off.""" @@ -388,8 +445,9 @@ async def _turn_away_mode_off(self) -> None: # Disabling all hold modes await self._device.set_hold_cool(False) await self._device.set_hold_heat(False) - except SomeComfortError: + except SomeComfortError as err: _LOGGER.error("Can not stop hold mode") + raise HomeAssistantError("Honeywell could not stop hold mode") from err async def async_set_preset_mode(self, preset_mode: str) -> None: """Set new preset mode.""" @@ -403,14 +461,22 @@ async def async_set_preset_mode(self, preset_mode: str) -> None: async def async_turn_aux_heat_on(self) -> None: """Turn auxiliary heater on.""" - await self._device.set_system_mode("emheat") + try: + await self._device.set_system_mode("emheat") + except SomeComfortError as err: + raise HomeAssistantError( + "Honeywell could not set system mode to aux heat." + ) from err async def async_turn_aux_heat_off(self) -> None: """Turn auxiliary heater off.""" - if HVACMode.HEAT in self.hvac_modes: - await self.async_set_hvac_mode(HVACMode.HEAT) - else: - await self.async_set_hvac_mode(HVACMode.OFF) + try: + if HVACMode.HEAT in self.hvac_modes: + await self.async_set_hvac_mode(HVACMode.HEAT) + else: + await self.async_set_hvac_mode(HVACMode.OFF) + except HomeAssistantError as err: + raise HomeAssistantError("Honeywell could turn off aux heat mode.") from err async def async_update(self) -> None: """Get the latest state from the service.""" diff --git a/homeassistant/components/honeywell/diagnostics.py b/homeassistant/components/honeywell/diagnostics.py new file mode 100644 index 00000000000000..4aebfc4c905e3a --- /dev/null +++ b/homeassistant/components/honeywell/diagnostics.py @@ -0,0 +1,33 @@ +"""Diagnostics support for Honeywell.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from . import HoneywellData +from .const import DOMAIN + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, + config_entry: ConfigEntry, +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + + Honeywell: HoneywellData = hass.data[DOMAIN][config_entry.entry_id] + diagnostics_data = {} + + for device, module in Honeywell.devices.items(): + diagnostics_data.update( + { + f"Device {device}": { + "UI Data": module.raw_ui_data, + "Fan Data": module.raw_fan_data, + "DR Data": module.raw_dr_data, + } + } + ) + + return diagnostics_data diff --git a/homeassistant/components/http/manifest.json b/homeassistant/components/http/manifest.json index dec1b9485b6921..bce425adbdb6e9 100644 --- a/homeassistant/components/http/manifest.json +++ b/homeassistant/components/http/manifest.json @@ -6,5 +6,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["aiohttp-cors==0.7.0"] + "requirements": ["aiohttp_cors==0.7.0"] } diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py index f21f084a544f14..929ca0193af670 100644 --- a/homeassistant/components/huawei_lte/__init__.py +++ b/homeassistant/components/huawei_lte/__init__.py @@ -143,9 +143,11 @@ class Router: url: str data: dict[str, Any] = field(default_factory=dict, init=False) - subscriptions: dict[str, set[str]] = field( + # Values are lists rather than sets, because the same item may be used by more than + # one thing, such as MonthDuration for CurrentMonth{Download,Upload}. + subscriptions: dict[str, list[str]] = field( default_factory=lambda: defaultdict( - set, ((x, {"initial_scan"}) for x in ALL_KEYS) + list, ((x, ["initial_scan"]) for x in ALL_KEYS) ), init=False, ) diff --git a/homeassistant/components/huawei_lte/binary_sensor.py b/homeassistant/components/huawei_lte/binary_sensor.py index 9966b9cc5f5df5..2d96a4e04260fd 100644 --- a/homeassistant/components/huawei_lte/binary_sensor.py +++ b/homeassistant/components/huawei_lte/binary_sensor.py @@ -52,15 +52,12 @@ async def async_setup_entry( class HuaweiLteBaseBinarySensor(HuaweiLteBaseEntityWithDevice, BinarySensorEntity): """Huawei LTE binary sensor device base class.""" + _attr_entity_registry_enabled_default = False + key: str = field(init=False) item: str = field(init=False) _raw_state: str | None = field(default=None, init=False) - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - return False - @property def _device_unique_id(self) -> str: return f"{self.key}.{self.item}" @@ -68,7 +65,9 @@ def _device_unique_id(self) -> str: async def async_added_to_hass(self) -> None: """Subscribe to needed data on add.""" await super().async_added_to_hass() - self.router.subscriptions[self.key].add(f"{BINARY_SENSOR_DOMAIN}/{self.item}") + self.router.subscriptions[self.key].append( + f"{BINARY_SENSOR_DOMAIN}/{self.item}" + ) async def async_will_remove_from_hass(self) -> None: """Unsubscribe from needed data on remove.""" @@ -106,6 +105,7 @@ class HuaweiLteMobileConnectionBinarySensor(HuaweiLteBaseBinarySensor): """Huawei LTE mobile connection binary sensor.""" _attr_name: str = field(default="Mobile connection", init=False) + _attr_entity_registry_enabled_default = True def __post_init__(self) -> None: """Initialize identifiers.""" @@ -135,11 +135,6 @@ def icon(self) -> str: """Return mobile connectivity sensor icon.""" return "mdi:signal" if self.is_on else "mdi:signal-off" - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - return True - @property def extra_state_attributes(self) -> dict[str, Any] | None: """Get additional attributes related to connection status.""" diff --git a/homeassistant/components/huawei_lte/device_tracker.py b/homeassistant/components/huawei_lte/device_tracker.py index b8833b24d921e4..665c96e4888410 100644 --- a/homeassistant/components/huawei_lte/device_tracker.py +++ b/homeassistant/components/huawei_lte/device_tracker.py @@ -90,8 +90,8 @@ async def async_setup_entry( async_add_entities(known_entities, True) # Tell parent router to poll hosts list to gather new devices - router.subscriptions[KEY_LAN_HOST_INFO].add(_DEVICE_SCAN) - router.subscriptions[KEY_WLAN_HOST_LIST].add(_DEVICE_SCAN) + router.subscriptions[KEY_LAN_HOST_INFO].append(_DEVICE_SCAN) + router.subscriptions[KEY_WLAN_HOST_LIST].append(_DEVICE_SCAN) async def _async_maybe_add_new_entities(unique_id: str) -> None: """Add new entities if the update signal comes from our router.""" diff --git a/homeassistant/components/huawei_lte/sensor.py b/homeassistant/components/huawei_lte/sensor.py index 133b569c75159e..a4321bfd93f70d 100644 --- a/homeassistant/components/huawei_lte/sensor.py +++ b/homeassistant/components/huawei_lte/sensor.py @@ -724,9 +724,9 @@ def __post_init__(self) -> None: async def async_added_to_hass(self) -> None: """Subscribe to needed data on add.""" await super().async_added_to_hass() - self.router.subscriptions[self.key].add(f"{SENSOR_DOMAIN}/{self.item}") + self.router.subscriptions[self.key].append(f"{SENSOR_DOMAIN}/{self.item}") if self.entity_description.last_reset_item: - self.router.subscriptions[self.key].add( + self.router.subscriptions[self.key].append( f"{SENSOR_DOMAIN}/{self.entity_description.last_reset_item}" ) diff --git a/homeassistant/components/huawei_lte/switch.py b/homeassistant/components/huawei_lte/switch.py index 2fe064d630092c..f75cf14e89b42d 100644 --- a/homeassistant/components/huawei_lte/switch.py +++ b/homeassistant/components/huawei_lte/switch.py @@ -69,7 +69,7 @@ def turn_off(self, **kwargs: Any) -> None: async def async_added_to_hass(self) -> None: """Subscribe to needed data on add.""" await super().async_added_to_hass() - self.router.subscriptions[self.key].add(f"{SWITCH_DOMAIN}/{self.item}") + self.router.subscriptions[self.key].append(f"{SWITCH_DOMAIN}/{self.item}") async def async_will_remove_from_hass(self) -> None: """Unsubscribe from needed data on remove.""" diff --git a/homeassistant/components/hue/config_flow.py b/homeassistant/components/hue/config_flow.py index 9c8dda94c942bf..0957329abb0b83 100644 --- a/homeassistant/components/hue/config_flow.py +++ b/homeassistant/components/hue/config_flow.py @@ -22,7 +22,6 @@ config_validation as cv, device_registry as dr, ) -from homeassistant.util.network import is_ipv6_address from .const import ( CONF_ALLOW_HUE_GROUPS, @@ -219,7 +218,7 @@ async def async_step_zeroconf( host is already configured and delegate to the import step if not. """ # Ignore if host is IPv6 - if is_ipv6_address(discovery_info.host): + if discovery_info.ip_address.version == 6: return self.async_abort(reason="invalid_host") # abort if we already have exactly this bridge id/host diff --git a/homeassistant/components/hue/strings.json b/homeassistant/components/hue/strings.json index 6d65abc8d5f3b3..1224abb240e4a9 100644 --- a/homeassistant/components/hue/strings.json +++ b/homeassistant/components/hue/strings.json @@ -31,7 +31,7 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "not_hue_bridge": "Not a Hue bridge", - "invalid_host": "Invalid host" + "invalid_host": "[%key:common::config_flow::error::invalid_host%]" } }, "device_automation": { @@ -94,6 +94,16 @@ } } } + }, + "sensor": { + "zigbee_connectivity": { + "state": { + "connected": "[%key:common::state::connected%]", + "disconnected": "[%key:common::state::disconnected%]", + "connectivity_issue": "Connectivity issue", + "unidirectional_incoming": "Unidirectional incoming" + } + } } }, "options": { diff --git a/homeassistant/components/hue/v2/sensor.py b/homeassistant/components/hue/v2/sensor.py index dcdae0a3294ee4..cc36edb88b2976 100644 --- a/homeassistant/components/hue/v2/sensor.py +++ b/homeassistant/components/hue/v2/sensor.py @@ -156,6 +156,14 @@ class HueZigbeeConnectivitySensor(HueSensorBase): """Representation of a Hue ZigbeeConnectivity sensor.""" _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_translation_key = "zigbee_connectivity" + _attr_device_class = SensorDeviceClass.ENUM + _attr_options = [ + "connected", + "disconnected", + "connectivity_issue", + "unidirectional_incoming", + ] _attr_entity_registry_enabled_default = False @property diff --git a/homeassistant/components/humidifier/__init__.py b/homeassistant/components/humidifier/__init__.py index a525c626f143a5..47745c53394dc4 100644 --- a/homeassistant/components/humidifier/__init__.py +++ b/homeassistant/components/humidifier/__init__.py @@ -134,6 +134,10 @@ class HumidifierEntityDescription(ToggleEntityDescription): class HumidifierEntity(ToggleEntity): """Base class for humidifier entities.""" + _entity_component_unrecorded_attributes = frozenset( + {ATTR_MIN_HUMIDITY, ATTR_MAX_HUMIDITY, ATTR_AVAILABLE_MODES} + ) + entity_description: HumidifierEntityDescription _attr_action: HumidifierAction | None = None _attr_available_modes: list[str] | None diff --git a/homeassistant/components/humidifier/recorder.py b/homeassistant/components/humidifier/recorder.py deleted file mode 100644 index 53df96605d6931..00000000000000 --- a/homeassistant/components/humidifier/recorder.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.core import HomeAssistant, callback - -from . import ATTR_AVAILABLE_MODES, ATTR_MAX_HUMIDITY, ATTR_MIN_HUMIDITY - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude static attributes from being recorded in the database.""" - return { - ATTR_MIN_HUMIDITY, - ATTR_MAX_HUMIDITY, - ATTR_AVAILABLE_MODES, - } diff --git a/homeassistant/components/humidifier/strings.json b/homeassistant/components/humidifier/strings.json index 19a9a8eab77233..1cdad10f2fb14b 100644 --- a/homeassistant/components/humidifier/strings.json +++ b/homeassistant/components/humidifier/strings.json @@ -33,7 +33,7 @@ "state": { "humidifying": "Humidifying", "drying": "Drying", - "idle": "Idle", + "idle": "[%key:common::state::idle%]", "off": "[%key:common::state::off%]" } }, @@ -60,7 +60,7 @@ "away": "Away", "boost": "Boost", "comfort": "Comfort", - "home": "Home", + "home": "[%key:common::state::home%]", "sleep": "Sleep", "auto": "Auto", "baby": "Baby" diff --git a/homeassistant/components/hunterdouglas_powerview/cover.py b/homeassistant/components/hunterdouglas_powerview/cover.py index 833c1812ddbdfe..18fe1cd0a6988c 100644 --- a/homeassistant/components/hunterdouglas_powerview/cover.py +++ b/homeassistant/components/hunterdouglas_powerview/cover.py @@ -311,6 +311,7 @@ async def _async_force_refresh_state(self) -> None: await self.async_update() self.async_write_ha_state() + # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: """When entity is added to hass.""" self.async_on_remove( diff --git a/homeassistant/components/hunterdouglas_powerview/scene.py b/homeassistant/components/hunterdouglas_powerview/scene.py index ba1221a25accdd..0c09917d35b52c 100644 --- a/homeassistant/components/hunterdouglas_powerview/scene.py +++ b/homeassistant/components/hunterdouglas_powerview/scene.py @@ -35,25 +35,14 @@ async def async_setup_entry( class PowerViewScene(HDEntity, Scene): """Representation of a Powerview scene.""" + _attr_icon = "mdi:blinds" + def __init__(self, coordinator, device_info, room_name, scene): """Initialize the scene.""" super().__init__(coordinator, device_info, room_name, scene.id) self._scene = scene - - @property - def name(self): - """Return the name of the scene.""" - return self._scene.name - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - return {STATE_ATTRIBUTE_ROOM_NAME: self._room_name} - - @property - def icon(self): - """Icon to use in the frontend.""" - return "mdi:blinds" + self._attr_name = scene.name + self._attr_extra_state_attributes = {STATE_ATTRIBUTE_ROOM_NAME: room_name} async def async_activate(self, **kwargs: Any) -> None: """Activate scene. Try to get entities into requested state.""" diff --git a/homeassistant/components/hunterdouglas_powerview/sensor.py b/homeassistant/components/hunterdouglas_powerview/sensor.py index 825ca140f148e6..330e5dddfa5817 100644 --- a/homeassistant/components/hunterdouglas_powerview/sensor.py +++ b/homeassistant/components/hunterdouglas_powerview/sensor.py @@ -136,6 +136,7 @@ def native_value(self) -> int: """Get the current value in percentage.""" return self.entity_description.native_value_fn(self._shade) + # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: """When entity is added to hass.""" self.async_on_remove( diff --git a/homeassistant/components/hvv_departures/binary_sensor.py b/homeassistant/components/hvv_departures/binary_sensor.py index 513c8dbd8b03b5..2eeb633921446e 100644 --- a/homeassistant/components/hvv_departures/binary_sensor.py +++ b/homeassistant/components/hvv_departures/binary_sensor.py @@ -192,6 +192,7 @@ def extra_state_attributes(self) -> dict[str, Any] | None: if v is not None } + # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: """When entity is added to hass.""" self.async_on_remove( diff --git a/homeassistant/components/hydrawise/binary_sensor.py b/homeassistant/components/hydrawise/binary_sensor.py index 63fe28cd40048d..9298e605791e86 100644 --- a/homeassistant/components/hydrawise/binary_sensor.py +++ b/homeassistant/components/hydrawise/binary_sensor.py @@ -90,7 +90,7 @@ def _handle_coordinator_update(self) -> None: """Get the latest data and updates the state.""" LOGGER.debug("Updating Hydrawise binary sensor: %s", self.name) if self.entity_description.key == "status": - self._attr_is_on = self.coordinator.api.status == "All good!" + self._attr_is_on = self.coordinator.last_update_success elif self.entity_description.key == "is_watering": relay_data = self.coordinator.api.relays_by_zone_number[self.data["relay"]] self._attr_is_on = relay_data["timestr"] == "Now" diff --git a/homeassistant/components/hyperion/camera.py b/homeassistant/components/hyperion/camera.py index 9c9e509947db80..23ce27151404b8 100644 --- a/homeassistant/components/hyperion/camera.py +++ b/homeassistant/components/hyperion/camera.py @@ -119,7 +119,7 @@ def __init__( """Initialize the switch.""" super().__init__() - self._unique_id = get_hyperion_unique_id( + self._attr_unique_id = get_hyperion_unique_id( server_id, instance_num, TYPE_HYPERION_CAMERA ) self._device_id = get_hyperion_device_id(server_id, instance_num) @@ -135,11 +135,13 @@ def __init__( self._client_callbacks = { f"{KEY_LEDCOLORS}-{KEY_IMAGE_STREAM}-{KEY_UPDATE}": self._update_imagestream } - - @property - def unique_id(self) -> str: - """Return a unique id for this instance.""" - return self._unique_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._device_id)}, + manufacturer=HYPERION_MANUFACTURER_NAME, + model=HYPERION_MODEL_NAME, + name=instance_name, + configuration_url=hyperion_client.remote_url, + ) @property def is_on(self) -> bool: @@ -231,7 +233,7 @@ async def async_added_to_hass(self) -> None: self.async_on_remove( async_dispatcher_connect( self.hass, - SIGNAL_ENTITY_REMOVE.format(self._unique_id), + SIGNAL_ENTITY_REMOVE.format(self._attr_unique_id), functools.partial(self.async_remove, force_remove=True), ) ) @@ -242,17 +244,6 @@ async def async_will_remove_from_hass(self) -> None: """Cleanup prior to hass removal.""" self._client.remove_callbacks(self._client_callbacks) - @property - def device_info(self) -> DeviceInfo: - """Return device information.""" - return DeviceInfo( - identifiers={(DOMAIN, self._device_id)}, - manufacturer=HYPERION_MANUFACTURER_NAME, - model=HYPERION_MODEL_NAME, - name=self._instance_name, - configuration_url=self._client.remote_url, - ) - CAMERA_TYPES = { TYPE_HYPERION_CAMERA: HyperionCamera, diff --git a/homeassistant/components/hyperion/light.py b/homeassistant/components/hyperion/light.py index 105e577efad7f8..824d83591efa5a 100644 --- a/homeassistant/components/hyperion/light.py +++ b/homeassistant/components/hyperion/light.py @@ -132,7 +132,7 @@ def __init__( hyperion_client: client.HyperionClient, ) -> None: """Initialize the light.""" - self._unique_id = self._compute_unique_id(server_id, instance_num) + self._attr_unique_id = self._compute_unique_id(server_id, instance_num) self._device_id = get_hyperion_device_id(server_id, instance_num) self._instance_name = instance_name self._options = options @@ -153,16 +153,18 @@ def __init__( f"{const.KEY_PRIORITIES}-{const.KEY_UPDATE}": self._update_priorities, f"{const.KEY_CLIENT}-{const.KEY_UPDATE}": self._update_client, } + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._device_id)}, + manufacturer=HYPERION_MANUFACTURER_NAME, + model=HYPERION_MODEL_NAME, + name=self._instance_name, + configuration_url=self._client.remote_url, + ) def _compute_unique_id(self, server_id: str, instance_num: int) -> str: """Compute a unique id for this instance.""" return get_hyperion_unique_id(server_id, instance_num, TYPE_HYPERION_LIGHT) - @property - def entity_registry_enabled_default(self) -> bool: - """Whether or not the entity is enabled by default.""" - return True - @property def brightness(self) -> int: """Return the brightness of this light between 0..255.""" @@ -196,22 +198,6 @@ def available(self) -> bool: """Return server availability.""" return bool(self._client.has_loaded_state) - @property - def unique_id(self) -> str: - """Return a unique id for this instance.""" - return self._unique_id - - @property - def device_info(self) -> DeviceInfo: - """Return device information.""" - return DeviceInfo( - identifiers={(DOMAIN, self._device_id)}, - manufacturer=HYPERION_MANUFACTURER_NAME, - model=HYPERION_MODEL_NAME, - name=self._instance_name, - configuration_url=self._client.remote_url, - ) - def _get_option(self, key: str) -> Any: """Get a value from the provided options.""" defaults = { diff --git a/homeassistant/components/hyperion/switch.py b/homeassistant/components/hyperion/switch.py index 11e1dc199be6cf..eb7b260a3700c9 100644 --- a/homeassistant/components/hyperion/switch.py +++ b/homeassistant/components/hyperion/switch.py @@ -133,6 +133,8 @@ class HyperionComponentSwitch(SwitchEntity): _attr_entity_category = EntityCategory.CONFIG _attr_should_poll = False _attr_has_entity_name = True + # These component controls are for advanced users and are disabled by default. + _attr_entity_registry_enabled_default = False def __init__( self, @@ -143,7 +145,7 @@ def __init__( hyperion_client: client.HyperionClient, ) -> None: """Initialize the switch.""" - self._unique_id = _component_to_unique_id( + self._attr_unique_id = _component_to_unique_id( server_id, component_name, instance_num ) self._device_id = get_hyperion_device_id(server_id, instance_num) @@ -154,17 +156,13 @@ def __init__( self._client_callbacks = { f"{KEY_COMPONENTS}-{KEY_UPDATE}": self._update_components } - - @property - def entity_registry_enabled_default(self) -> bool: - """Whether or not the entity is enabled by default.""" - # These component controls are for advanced users and are disabled by default. - return False - - @property - def unique_id(self) -> str: - """Return a unique id for this instance.""" - return self._unique_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._device_id)}, + manufacturer=HYPERION_MANUFACTURER_NAME, + model=HYPERION_MODEL_NAME, + name=self._instance_name, + configuration_url=self._client.remote_url, + ) @property def is_on(self) -> bool: @@ -179,17 +177,6 @@ def available(self) -> bool: """Return server availability.""" return bool(self._client.has_loaded_state) - @property - def device_info(self) -> DeviceInfo: - """Return device information.""" - return DeviceInfo( - identifiers={(DOMAIN, self._device_id)}, - manufacturer=HYPERION_MANUFACTURER_NAME, - model=HYPERION_MODEL_NAME, - name=self._instance_name, - configuration_url=self._client.remote_url, - ) - async def _async_send_set_component(self, value: bool) -> None: """Send a component control request.""" await self._client.async_send_set_component( @@ -219,7 +206,7 @@ async def async_added_to_hass(self) -> None: self.async_on_remove( async_dispatcher_connect( self.hass, - SIGNAL_ENTITY_REMOVE.format(self._unique_id), + SIGNAL_ENTITY_REMOVE.format(self._attr_unique_id), functools.partial(self.async_remove, force_remove=True), ) ) diff --git a/homeassistant/components/iaqualink/__init__.py b/homeassistant/components/iaqualink/__init__.py index 9554d30df45ffb..fceb0d72213751 100644 --- a/homeassistant/components/iaqualink/__init__.py +++ b/homeassistant/components/iaqualink/__init__.py @@ -6,7 +6,7 @@ from datetime import datetime from functools import wraps import logging -from typing import Any, Concatenate, ParamSpec, TypeVar, cast +from typing import Any, Concatenate, ParamSpec, TypeVar import httpx from iaqualink.client import AqualinkClient @@ -215,6 +215,14 @@ class AqualinkEntity(Entity): def __init__(self, dev: AqualinkDevice) -> None: """Initialize the entity.""" self.dev = dev + self._attr_unique_id = f"{dev.system.serial}_{dev.name}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._attr_unique_id)}, + manufacturer=dev.manufacturer, + model=dev.model, + name=dev.label, + via_device=(DOMAIN, dev.system.serial), + ) async def async_added_to_hass(self) -> None: """Set up a listener when this entity is added to HA.""" @@ -222,11 +230,6 @@ async def async_added_to_hass(self) -> None: async_dispatcher_connect(self.hass, DOMAIN, self.async_write_ha_state) ) - @property - def unique_id(self) -> str: - """Return a unique identifier for this entity.""" - return f"{self.dev.system.serial}_{self.dev.name}" - @property def assumed_state(self) -> bool: """Return whether the state is based on actual reading from the device.""" @@ -236,16 +239,3 @@ def assumed_state(self) -> bool: def available(self) -> bool: """Return whether the device is available or not.""" return self.dev.system.online is True - - @property - def device_info(self) -> DeviceInfo: - """Return the device info.""" - return DeviceInfo( - identifiers={(DOMAIN, self.unique_id)}, - manufacturer=self.dev.manufacturer, - model=self.dev.model, - # Instead of setting the device name to the entity name, iaqualink - # should be updated to set has_entity_name = True - name=cast(str | None, self.name), - via_device=(DOMAIN, self.dev.system.serial), - ) diff --git a/homeassistant/components/iaqualink/binary_sensor.py b/homeassistant/components/iaqualink/binary_sensor.py index 7513a15272c4d2..149261f97fc05a 100644 --- a/homeassistant/components/iaqualink/binary_sensor.py +++ b/homeassistant/components/iaqualink/binary_sensor.py @@ -1,6 +1,8 @@ """Support for Aqualink temperature sensors.""" from __future__ import annotations +from iaqualink.device import AqualinkBinarySensor + from homeassistant.components.binary_sensor import ( DOMAIN, BinarySensorDeviceClass, @@ -31,19 +33,14 @@ async def async_setup_entry( class HassAqualinkBinarySensor(AqualinkEntity, BinarySensorEntity): """Representation of a binary sensor.""" - @property - def name(self) -> str: - """Return the name of the binary sensor.""" - return self.dev.label + def __init__(self, dev: AqualinkBinarySensor) -> None: + """Initialize AquaLink binary sensor.""" + super().__init__(dev) + self._attr_name = dev.label + if dev.label == "Freeze Protection": + self._attr_device_class = BinarySensorDeviceClass.COLD @property def is_on(self) -> bool: """Return whether the binary sensor is on or not.""" return self.dev.is_on - - @property - def device_class(self) -> BinarySensorDeviceClass | None: - """Return the class of the binary sensor.""" - if self.name == "Freeze Protection": - return BinarySensorDeviceClass.COLD - return None diff --git a/homeassistant/components/iaqualink/climate.py b/homeassistant/components/iaqualink/climate.py index 7c67dbdea4b3fb..b7dbe43fca99c3 100644 --- a/homeassistant/components/iaqualink/climate.py +++ b/homeassistant/components/iaqualink/climate.py @@ -4,6 +4,8 @@ import logging from typing import Any +from iaqualink.device import AqualinkThermostat + from homeassistant.components.climate import ( DOMAIN as CLIMATE_DOMAIN, ClimateEntity, @@ -42,10 +44,17 @@ class HassAqualinkThermostat(AqualinkEntity, ClimateEntity): _attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF] _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE - @property - def name(self) -> str: - """Return the name of the thermostat.""" - return self.dev.label.split(" ")[0] + def __init__(self, dev: AqualinkThermostat) -> None: + """Initialize AquaLink thermostat.""" + super().__init__(dev) + self._attr_name = dev.label.split(" ")[0] + self._attr_temperature_unit = ( + UnitOfTemperature.FAHRENHEIT + if dev.unit == "F" + else UnitOfTemperature.CELSIUS + ) + self._attr_min_temp = dev.min_temperature + self._attr_max_temp = dev.max_temperature @property def hvac_mode(self) -> HVACMode: @@ -64,23 +73,6 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: else: _LOGGER.warning("Unknown operation mode: %s", hvac_mode) - @property - def temperature_unit(self) -> str: - """Return the unit of measurement.""" - if self.dev.unit == "F": - return UnitOfTemperature.FAHRENHEIT - return UnitOfTemperature.CELSIUS - - @property - def min_temp(self) -> int: - """Return the minimum temperature supported by the thermostat.""" - return self.dev.min_temperature - - @property - def max_temp(self) -> int: - """Return the minimum temperature supported by the thermostat.""" - return self.dev.max_temperature - @property def target_temperature(self) -> float: """Return the current target temperature.""" diff --git a/homeassistant/components/iaqualink/light.py b/homeassistant/components/iaqualink/light.py index 8b83f7019152b6..3a166ba593ddaa 100644 --- a/homeassistant/components/iaqualink/light.py +++ b/homeassistant/components/iaqualink/light.py @@ -3,6 +3,8 @@ from typing import Any +from iaqualink.device import AqualinkLight + from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_EFFECT, @@ -37,10 +39,18 @@ async def async_setup_entry( class HassAqualinkLight(AqualinkEntity, LightEntity): """Representation of a light.""" - @property - def name(self) -> str: - """Return the name of the light.""" - return self.dev.label + def __init__(self, dev: AqualinkLight) -> None: + """Initialize AquaLink light.""" + super().__init__(dev) + self._attr_name = dev.label + if dev.supports_effect: + self._attr_effect_list = list(dev.supported_effects) + self._attr_supported_features = LightEntityFeature.EFFECT + color_mode = ColorMode.ONOFF + if dev.supports_brightness: + color_mode = ColorMode.BRIGHTNESS + self._attr_color_mode = color_mode + self._attr_supported_color_modes = {color_mode} @property def is_on(self) -> bool: @@ -81,28 +91,3 @@ def brightness(self) -> int: def effect(self) -> str: """Return the current light effect if supported.""" return self.dev.effect - - @property - def effect_list(self) -> list[str]: - """Return supported light effects.""" - return list(self.dev.supported_effects) - - @property - def color_mode(self) -> ColorMode: - """Return the color mode of the light.""" - if self.dev.supports_brightness: - return ColorMode.BRIGHTNESS - return ColorMode.ONOFF - - @property - def supported_color_modes(self) -> set[ColorMode]: - """Flag supported color modes.""" - return {self.color_mode} - - @property - def supported_features(self) -> LightEntityFeature: - """Return the list of features supported by the light.""" - if self.dev.supports_effect: - return LightEntityFeature.EFFECT - - return LightEntityFeature(0) diff --git a/homeassistant/components/iaqualink/sensor.py b/homeassistant/components/iaqualink/sensor.py index 8086aa29ee0de0..15e8fc5836d0af 100644 --- a/homeassistant/components/iaqualink/sensor.py +++ b/homeassistant/components/iaqualink/sensor.py @@ -1,6 +1,8 @@ """Support for Aqualink temperature sensors.""" from __future__ import annotations +from iaqualink.device import AqualinkSensor + from homeassistant.components.sensor import DOMAIN, SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfTemperature @@ -28,19 +30,17 @@ async def async_setup_entry( class HassAqualinkSensor(AqualinkEntity, SensorEntity): """Representation of a sensor.""" - @property - def name(self) -> str: - """Return the name of the sensor.""" - return self.dev.label - - @property - def native_unit_of_measurement(self) -> str | None: - """Return the measurement unit for the sensor.""" - if self.dev.name.endswith("_temp"): - if self.dev.system.temp_unit == "F": - return UnitOfTemperature.FAHRENHEIT - return UnitOfTemperature.CELSIUS - return None + def __init__(self, dev: AqualinkSensor) -> None: + """Initialize AquaLink sensor.""" + super().__init__(dev) + self._attr_name = dev.label + if not dev.name.endswith("_temp"): + return + self._attr_device_class = SensorDeviceClass.TEMPERATURE + if dev.system.temp_unit == "F": + self._attr_native_unit_of_measurement = UnitOfTemperature.FAHRENHEIT + return + self._attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS @property def native_value(self) -> int | float | None: @@ -52,10 +52,3 @@ def native_value(self) -> int | float | None: return int(self.dev.state) except ValueError: return float(self.dev.state) - - @property - def device_class(self) -> SensorDeviceClass | None: - """Return the class of the sensor.""" - if self.dev.name.endswith("_temp"): - return SensorDeviceClass.TEMPERATURE - return None diff --git a/homeassistant/components/iaqualink/switch.py b/homeassistant/components/iaqualink/switch.py index 8f482e8730f351..590fcd6141966e 100644 --- a/homeassistant/components/iaqualink/switch.py +++ b/homeassistant/components/iaqualink/switch.py @@ -3,6 +3,8 @@ from typing import Any +from iaqualink.device import AqualinkSwitch + from homeassistant.components.switch import DOMAIN, SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -30,23 +32,18 @@ async def async_setup_entry( class HassAqualinkSwitch(AqualinkEntity, SwitchEntity): """Representation of a switch.""" - @property - def name(self) -> str: - """Return the name of the switch.""" - return self.dev.label - - @property - def icon(self) -> str | None: - """Return an icon based on the switch type.""" - if self.name == "Cleaner": - return "mdi:robot-vacuum" - if self.name == "Waterfall" or self.name.endswith("Dscnt"): - return "mdi:fountain" - if self.name.endswith("Pump") or self.name.endswith("Blower"): - return "mdi:fan" - if self.name.endswith("Heater"): - return "mdi:radiator" - return None + def __init__(self, dev: AqualinkSwitch) -> None: + """Initialize AquaLink switch.""" + super().__init__(dev) + name = self._attr_name = dev.label + if name == "Cleaner": + self._attr_icon = "mdi:robot-vacuum" + elif name == "Waterfall" or name.endswith("Dscnt"): + self._attr_icon = "mdi:fountain" + elif name.endswith("Pump") or name.endswith("Blower"): + self._attr_icon = "mdi:fan" + if name.endswith("Heater"): + self._attr_icon = "mdi:radiator" @property def is_on(self) -> bool: diff --git a/homeassistant/components/idasen_desk/__init__.py b/homeassistant/components/idasen_desk/__init__.py new file mode 100644 index 00000000000000..5fd23ba47e02ca --- /dev/null +++ b/homeassistant/components/idasen_desk/__init__.py @@ -0,0 +1,94 @@ +"""The IKEA Idasen Desk integration.""" +from __future__ import annotations + +import logging + +from attr import dataclass +from bleak import BleakError +from idasen_ha import Desk + +from homeassistant.components import bluetooth +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + ATTR_NAME, + CONF_ADDRESS, + EVENT_HOMEASSISTANT_STOP, + Platform, +) +from homeassistant.core import Event, HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN + +PLATFORMS: list[Platform] = [Platform.COVER] + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class DeskData: + """Data for the Idasen Desk integration.""" + + desk: Desk + address: str + device_info: DeviceInfo + coordinator: DataUpdateCoordinator + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up IKEA Idasen from a config entry.""" + address: str = entry.data[CONF_ADDRESS].upper() + + coordinator: DataUpdateCoordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name=entry.title, + ) + + desk = Desk(coordinator.async_set_updated_data) + device_info = DeviceInfo( + name=entry.title, + connections={(dr.CONNECTION_BLUETOOTH, address)}, + ) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = DeskData( + desk, address, device_info, coordinator + ) + + ble_device = bluetooth.async_ble_device_from_address( + hass, address, connectable=True + ) + try: + await desk.connect(ble_device) + except (TimeoutError, BleakError) as ex: + raise ConfigEntryNotReady(f"Unable to connect to desk {address}") from ex + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) + + async def _async_stop(event: Event) -> None: + """Close the connection.""" + await desk.disconnect() + + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_stop) + ) + return True + + +async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle options update.""" + data: DeskData = hass.data[DOMAIN][entry.entry_id] + if entry.title != data.device_info[ATTR_NAME]: + await hass.config_entries.async_reload(entry.entry_id) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + data: DeskData = hass.data[DOMAIN].pop(entry.entry_id) + await data.desk.disconnect() + + return unload_ok diff --git a/homeassistant/components/idasen_desk/config_flow.py b/homeassistant/components/idasen_desk/config_flow.py new file mode 100644 index 00000000000000..f56446396d2c55 --- /dev/null +++ b/homeassistant/components/idasen_desk/config_flow.py @@ -0,0 +1,115 @@ +"""Config flow for Idasen Desk integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from bleak import BleakError +from bluetooth_data_tools import human_readable_name +from idasen_ha import Desk +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components.bluetooth import ( + BluetoothServiceInfoBleak, + async_discovered_service_info, +) +from homeassistant.const import CONF_ADDRESS +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN, EXPECTED_SERVICE_UUID + +_LOGGER = logging.getLogger(__name__) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Idasen Desk integration.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize the config flow.""" + self._discovery_info: BluetoothServiceInfoBleak | None = None + self._discovered_devices: dict[str, BluetoothServiceInfoBleak] = {} + + async def async_step_bluetooth( + self, discovery_info: BluetoothServiceInfoBleak + ) -> FlowResult: + """Handle the bluetooth discovery step.""" + await self.async_set_unique_id(discovery_info.address) + self._abort_if_unique_id_configured() + self._discovery_info = discovery_info + self.context["title_placeholders"] = { + "name": human_readable_name( + None, discovery_info.name, discovery_info.address + ) + } + return await self.async_step_user() + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the user step to pick discovered device.""" + errors: dict[str, str] = {} + + if user_input is not None: + address = user_input[CONF_ADDRESS] + discovery_info = self._discovered_devices[address] + local_name = discovery_info.name + await self.async_set_unique_id( + discovery_info.address, raise_on_progress=False + ) + self._abort_if_unique_id_configured() + + desk = Desk(None) + try: + await desk.connect(discovery_info.device, monitor_height=False) + except TimeoutError as err: + _LOGGER.exception("TimeoutError", exc_info=err) + errors["base"] = "cannot_connect" + except BleakError as err: + _LOGGER.exception("BleakError", exc_info=err) + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected error") + errors["base"] = "unknown" + else: + await desk.disconnect() + return self.async_create_entry( + title=local_name, + data={ + CONF_ADDRESS: discovery_info.address, + }, + ) + + if discovery := self._discovery_info: + self._discovered_devices[discovery.address] = discovery + else: + current_addresses = self._async_current_ids() + for discovery in async_discovered_service_info(self.hass): + if ( + discovery.address in current_addresses + or discovery.address in self._discovered_devices + or EXPECTED_SERVICE_UUID not in discovery.service_uuids + ): + continue + self._discovered_devices[discovery.address] = discovery + + if not self._discovered_devices: + return self.async_abort(reason="no_devices_found") + + data_schema = vol.Schema( + { + vol.Required(CONF_ADDRESS): vol.In( + { + service_info.address: f"{service_info.name} ({service_info.address})" + for service_info in self._discovered_devices.values() + } + ), + } + ) + return self.async_show_form( + step_id="user", + data_schema=data_schema, + errors=errors, + ) diff --git a/homeassistant/components/idasen_desk/const.py b/homeassistant/components/idasen_desk/const.py new file mode 100644 index 00000000000000..0d37d77307b3ab --- /dev/null +++ b/homeassistant/components/idasen_desk/const.py @@ -0,0 +1,6 @@ +"""Constants for the Idasen Desk integration.""" + + +DOMAIN = "idasen_desk" + +EXPECTED_SERVICE_UUID = "99fa0001-338a-1024-8a49-009c0215f78a" diff --git a/homeassistant/components/idasen_desk/cover.py b/homeassistant/components/idasen_desk/cover.py new file mode 100644 index 00000000000000..c1d1bb48fd8601 --- /dev/null +++ b/homeassistant/components/idasen_desk/cover.py @@ -0,0 +1,101 @@ +"""Idasen Desk integration cover platform.""" +from __future__ import annotations + +import logging +from typing import Any + +from idasen_ha import Desk + +from homeassistant.components.cover import ( + ATTR_POSITION, + CoverDeviceClass, + CoverEntity, + CoverEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_NAME +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from . import DeskData +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the cover platform for Idasen Desk.""" + data: DeskData = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + [IdasenDeskCover(data.desk, data.address, data.device_info, data.coordinator)] + ) + + +class IdasenDeskCover(CoordinatorEntity, CoverEntity): + """Representation of Idasen Desk device.""" + + _attr_device_class = CoverDeviceClass.DAMPER + _attr_icon = "mdi:desk" + _attr_supported_features = ( + CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.STOP + | CoverEntityFeature.SET_POSITION + ) + + def __init__( + self, + desk: Desk, + address: str, + device_info: DeviceInfo, + coordinator: DataUpdateCoordinator, + ) -> None: + """Initialize an Idasen Desk cover.""" + super().__init__(coordinator) + self._desk = desk + self._attr_name = device_info[ATTR_NAME] + self._attr_unique_id = address + self._attr_device_info = device_info + + self._attr_current_cover_position = self._desk.height_percent + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._desk.is_connected is True + + @property + def is_closed(self) -> bool: + """Return if the cover is closed.""" + return self.current_cover_position == 0 + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close the cover.""" + await self._desk.move_down() + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the cover.""" + await self._desk.move_up() + + async def async_stop_cover(self, **kwargs: Any) -> None: + """Stop the cover.""" + await self._desk.stop() + + async def async_set_cover_position(self, **kwargs: Any) -> None: + """Move the cover shutter to a specific position.""" + await self._desk.move_to(int(kwargs[ATTR_POSITION])) + + @callback + def _handle_coordinator_update(self, *args: Any) -> None: + """Handle data update.""" + self._attr_current_cover_position = self._desk.height_percent + self.async_write_ha_state() diff --git a/homeassistant/components/idasen_desk/manifest.json b/homeassistant/components/idasen_desk/manifest.json new file mode 100644 index 00000000000000..f77e0c22373397 --- /dev/null +++ b/homeassistant/components/idasen_desk/manifest.json @@ -0,0 +1,15 @@ +{ + "domain": "idasen_desk", + "name": "IKEA Idasen Desk", + "bluetooth": [ + { + "service_uuid": "99fa0001-338a-1024-8a49-009c0215f78a" + } + ], + "codeowners": ["@abmantis"], + "config_flow": true, + "dependencies": ["bluetooth_adapters"], + "documentation": "https://www.home-assistant.io/integrations/idasen_desk", + "iot_class": "local_push", + "requirements": ["idasen-ha==1.4"] +} diff --git a/homeassistant/components/idasen_desk/strings.json b/homeassistant/components/idasen_desk/strings.json new file mode 100644 index 00000000000000..e2be7e6deff147 --- /dev/null +++ b/homeassistant/components/idasen_desk/strings.json @@ -0,0 +1,22 @@ +{ + "config": { + "flow_title": "{name}", + "step": { + "user": { + "data": { + "address": "Bluetooth address" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "not_supported": "Device not supported", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" + } + } +} diff --git a/homeassistant/components/image/__init__.py b/homeassistant/components/image/__init__.py index d1895053f02351..e5c40affe0fd22 100644 --- a/homeassistant/components/image/__init__.py +++ b/homeassistant/components/image/__init__.py @@ -126,6 +126,10 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: class ImageEntity(Entity): """The base class for image entities.""" + _entity_component_unrecorded_attributes = frozenset( + {"access_token", "entity_picture"} + ) + # Entity Properties _attr_content_type: str = DEFAULT_CONTENT_TYPE _attr_image_last_updated: datetime | None = None diff --git a/homeassistant/components/image/recorder.py b/homeassistant/components/image/recorder.py deleted file mode 100644 index 5c14122088100c..00000000000000 --- a/homeassistant/components/image/recorder.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.core import HomeAssistant, callback - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude access_token and entity_picture from being recorded in the database.""" - return {"access_token", "entity_picture"} diff --git a/homeassistant/components/imap/coordinator.py b/homeassistant/components/imap/coordinator.py index 72be5e9bcf0364..59c24b11e51e1a 100644 --- a/homeassistant/components/imap/coordinator.py +++ b/homeassistant/components/imap/coordinator.py @@ -110,6 +110,15 @@ def headers(self) -> dict[str, tuple[str,]]: header_base[key] += header_instances # type: ignore[assignment] return header_base + @property + def message_id(self) -> str | None: + """Get the message ID.""" + value: str + for header, value in self.email_message.items(): + if header == "Message-ID": + return value + return None + @property def date(self) -> datetime | None: """Get the date the email was sent.""" @@ -189,6 +198,7 @@ def __init__( """Initiate imap client.""" self.imap_client = imap_client self.auth_errors: int = 0 + self._last_message_uid: str | None = None self._last_message_id: str | None = None self.custom_event_template = None _custom_event_template = entry.data.get(CONF_CUSTOM_EVENT_DATA_TEMPLATE) @@ -209,16 +219,22 @@ async def _async_reconnect_if_needed(self) -> None: if self.imap_client is None: self.imap_client = await connect_to_server(self.config_entry.data) - async def _async_process_event(self, last_message_id: str) -> None: + async def _async_process_event(self, last_message_uid: str) -> None: """Send a event for the last message if the last message was changed.""" - response = await self.imap_client.fetch(last_message_id, "BODY.PEEK[]") + response = await self.imap_client.fetch(last_message_uid, "BODY.PEEK[]") if response.result == "OK": message = ImapMessage(response.lines[1]) + # Set `initial` to `False` if the last message is triggered again + initial: bool = True + if (message_id := message.message_id) == self._last_message_id: + initial = False + self._last_message_id = message_id data = { "server": self.config_entry.data[CONF_SERVER], "username": self.config_entry.data[CONF_USERNAME], "search": self.config_entry.data[CONF_SEARCH], "folder": self.config_entry.data[CONF_FOLDER], + "initial": initial, "date": message.date, "text": message.text, "sender": message.sender, @@ -231,18 +247,20 @@ async def _async_process_event(self, last_message_id: str) -> None: data, parse_result=True ) _LOGGER.debug( - "imap custom template (%s) for msgid %s rendered to: %s", + "IMAP custom template (%s) for msguid %s (%s) rendered to: %s, initial: %s", self.custom_event_template, - last_message_id, + last_message_uid, + message_id, data["custom"], + initial, ) except TemplateError as err: data["custom"] = None _LOGGER.error( - "Error rendering imap custom template (%s) for msgid %s " + "Error rendering IMAP custom template (%s) for msguid %s " "failed with message: %s", self.custom_event_template, - last_message_id, + last_message_uid, err, ) data["text"] = message.text[ @@ -263,10 +281,12 @@ async def _async_process_event(self, last_message_id: str) -> None: self.hass.bus.fire(EVENT_IMAP, data) _LOGGER.debug( - "Message with id %s processed, sender: %s, subject: %s", - last_message_id, + "Message with id %s (%s) processed, sender: %s, subject: %s, initial: %s", + last_message_uid, + message_id, message.sender, message.subject, + initial, ) async def _async_fetch_number_of_messages(self) -> int | None: @@ -282,20 +302,20 @@ async def _async_fetch_number_of_messages(self) -> int | None: f"Invalid response for search '{self.config_entry.data[CONF_SEARCH]}': {result} / {lines[0]}" ) if not (count := len(message_ids := lines[0].split())): - self._last_message_id = None + self._last_message_uid = None return 0 - last_message_id = ( + last_message_uid = ( str(message_ids[-1:][0], encoding=self.config_entry.data[CONF_CHARSET]) if count else None ) if ( count - and last_message_id is not None - and self._last_message_id != last_message_id + and last_message_uid is not None + and self._last_message_uid != last_message_uid ): - self._last_message_id = last_message_id - await self._async_process_event(last_message_id) + self._last_message_uid = last_message_uid + await self._async_process_event(last_message_uid) return count diff --git a/homeassistant/components/imap_email_content/repairs.py b/homeassistant/components/imap_email_content/repairs.py index f19b0499040b0a..8fe05f80c0805e 100644 --- a/homeassistant/components/imap_email_content/repairs.py +++ b/homeassistant/components/imap_email_content/repairs.py @@ -79,7 +79,7 @@ async def async_process_issue(hass: HomeAssistant, config: ConfigType) -> None: hass, DOMAIN, issue_id, - breaks_in_ha_version="2023.10.0", + breaks_in_ha_version="2023.11.0", is_fixable=True, severity=ir.IssueSeverity.WARNING, translation_key="migration", @@ -143,7 +143,7 @@ async def async_step_confirm( self.hass, DOMAIN, self._issue_id, - breaks_in_ha_version="2023.10.0", + breaks_in_ha_version="2023.11.0", is_fixable=False, severity=ir.IssueSeverity.WARNING, translation_key="deprecation", diff --git a/homeassistant/components/input_boolean/__init__.py b/homeassistant/components/input_boolean/__init__.py index a074b3b9b650a3..613e8829aa1eda 100644 --- a/homeassistant/components/input_boolean/__init__.py +++ b/homeassistant/components/input_boolean/__init__.py @@ -22,9 +22,6 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import ToggleEntity from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.helpers.integration_platform import ( - async_process_integration_platform_for_component, -) from homeassistant.helpers.restore_state import RestoreEntity import homeassistant.helpers.service from homeassistant.helpers.storage import Store @@ -94,10 +91,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up an input boolean.""" component = EntityComponent[InputBoolean](_LOGGER, DOMAIN, hass) - # Process integration platforms right away since - # we will create entities before firing EVENT_COMPONENT_LOADED - await async_process_integration_platform_for_component(hass, DOMAIN) - id_manager = collection.IDManager() yaml_collection = collection.YamlCollection( @@ -156,6 +149,8 @@ async def reload_service_handler(service_call: ServiceCall) -> None: class InputBoolean(collection.CollectionEntity, ToggleEntity, RestoreEntity): """Representation of a boolean input.""" + _unrecorded_attributes = frozenset({ATTR_EDITABLE}) + _attr_should_poll = False editable: bool diff --git a/homeassistant/components/input_boolean/recorder.py b/homeassistant/components/input_boolean/recorder.py deleted file mode 100644 index 8e94dc93f3b6b7..00000000000000 --- a/homeassistant/components/input_boolean/recorder.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.const import ATTR_EDITABLE -from homeassistant.core import HomeAssistant, callback - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude editable hint from being recorded in the database.""" - return {ATTR_EDITABLE} diff --git a/homeassistant/components/input_button/__init__.py b/homeassistant/components/input_button/__init__.py index c04b18b0c25a3c..3318354392c089 100644 --- a/homeassistant/components/input_button/__init__.py +++ b/homeassistant/components/input_button/__init__.py @@ -18,9 +18,6 @@ from homeassistant.helpers import collection import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.helpers.integration_platform import ( - async_process_integration_platform_for_component, -) from homeassistant.helpers.restore_state import RestoreEntity import homeassistant.helpers.service from homeassistant.helpers.storage import Store @@ -79,10 +76,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up an input button.""" component = EntityComponent[InputButton](_LOGGER, DOMAIN, hass) - # Process integration platforms right away since - # we will create entities before firing EVENT_COMPONENT_LOADED - await async_process_integration_platform_for_component(hass, DOMAIN) - id_manager = collection.IDManager() yaml_collection = collection.YamlCollection( @@ -137,6 +130,8 @@ async def reload_service_handler(service_call: ServiceCall) -> None: class InputButton(collection.CollectionEntity, ButtonEntity, RestoreEntity): """Representation of a button.""" + _unrecorded_attributes = frozenset({ATTR_EDITABLE}) + _attr_should_poll = False editable: bool diff --git a/homeassistant/components/input_button/recorder.py b/homeassistant/components/input_button/recorder.py deleted file mode 100644 index 8e94dc93f3b6b7..00000000000000 --- a/homeassistant/components/input_button/recorder.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.const import ATTR_EDITABLE -from homeassistant.core import HomeAssistant, callback - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude editable hint from being recorded in the database.""" - return {ATTR_EDITABLE} diff --git a/homeassistant/components/input_datetime/__init__.py b/homeassistant/components/input_datetime/__init__.py index 81882137fad371..73a4df12d03f84 100644 --- a/homeassistant/components/input_datetime/__init__.py +++ b/homeassistant/components/input_datetime/__init__.py @@ -20,9 +20,6 @@ from homeassistant.helpers import collection import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.helpers.integration_platform import ( - async_process_integration_platform_for_component, -) from homeassistant.helpers.restore_state import RestoreEntity import homeassistant.helpers.service from homeassistant.helpers.storage import Store @@ -132,10 +129,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up an input datetime.""" component = EntityComponent[InputDatetime](_LOGGER, DOMAIN, hass) - # Process integration platforms right away since - # we will create entities before firing EVENT_COMPONENT_LOADED - await async_process_integration_platform_for_component(hass, DOMAIN) - id_manager = collection.IDManager() yaml_collection = collection.YamlCollection( @@ -225,6 +218,8 @@ async def _update_data(self, item: dict, update_data: dict) -> dict: class InputDatetime(collection.CollectionEntity, RestoreEntity): """Representation of a datetime input.""" + _unrecorded_attributes = frozenset({ATTR_EDITABLE, CONF_HAS_DATE, CONF_HAS_TIME}) + _attr_should_poll = False editable: bool diff --git a/homeassistant/components/input_datetime/recorder.py b/homeassistant/components/input_datetime/recorder.py deleted file mode 100644 index 91c33ee0811a86..00000000000000 --- a/homeassistant/components/input_datetime/recorder.py +++ /dev/null @@ -1,13 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.const import ATTR_EDITABLE -from homeassistant.core import HomeAssistant, callback - -from . import CONF_HAS_DATE, CONF_HAS_TIME - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude some attributes from being recorded in the database.""" - return {ATTR_EDITABLE, CONF_HAS_DATE, CONF_HAS_TIME} diff --git a/homeassistant/components/input_number/__init__.py b/homeassistant/components/input_number/__init__.py index 197a35246d214b..4a74201be15faf 100644 --- a/homeassistant/components/input_number/__init__.py +++ b/homeassistant/components/input_number/__init__.py @@ -21,9 +21,6 @@ from homeassistant.helpers import collection import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.helpers.integration_platform import ( - async_process_integration_platform_for_component, -) from homeassistant.helpers.restore_state import RestoreEntity import homeassistant.helpers.service from homeassistant.helpers.storage import Store @@ -110,10 +107,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up an input slider.""" component = EntityComponent[InputNumber](_LOGGER, DOMAIN, hass) - # Process integration platforms right away since - # we will create entities before firing EVENT_COMPONENT_LOADED - await async_process_integration_platform_for_component(hass, DOMAIN) - id_manager = collection.IDManager() yaml_collection = collection.YamlCollection( @@ -209,6 +202,10 @@ async def _update_data(self, item: dict, update_data: dict) -> dict: class InputNumber(collection.CollectionEntity, RestoreEntity): """Representation of a slider.""" + _unrecorded_attributes = frozenset( + {ATTR_EDITABLE, ATTR_MAX, ATTR_MIN, ATTR_MODE, ATTR_STEP} + ) + _attr_should_poll = False editable: bool diff --git a/homeassistant/components/input_number/recorder.py b/homeassistant/components/input_number/recorder.py deleted file mode 100644 index 05a5023be0b193..00000000000000 --- a/homeassistant/components/input_number/recorder.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.const import ATTR_EDITABLE -from homeassistant.core import HomeAssistant, callback - -from . import ATTR_MAX, ATTR_MIN, ATTR_MODE, ATTR_STEP - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude editable hint from being recorded in the database.""" - return { - ATTR_EDITABLE, - ATTR_MAX, - ATTR_MIN, - ATTR_MODE, - ATTR_STEP, - } diff --git a/homeassistant/components/input_select/__init__.py b/homeassistant/components/input_select/__init__.py index e1354cb26a505e..4a384e0c17a40e 100644 --- a/homeassistant/components/input_select/__init__.py +++ b/homeassistant/components/input_select/__init__.py @@ -29,9 +29,6 @@ from homeassistant.helpers import collection import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.helpers.integration_platform import ( - async_process_integration_platform_for_component, -) from homeassistant.helpers.restore_state import RestoreEntity import homeassistant.helpers.service from homeassistant.helpers.storage import Store @@ -138,10 +135,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up an input select.""" component = EntityComponent[InputSelect](_LOGGER, DOMAIN, hass) - # Process integration platforms right away since - # we will create entities before firing EVENT_COMPONENT_LOADED - await async_process_integration_platform_for_component(hass, DOMAIN) - id_manager = collection.IDManager() yaml_collection = collection.YamlCollection( @@ -255,6 +248,11 @@ async def _update_data( class InputSelect(collection.CollectionEntity, SelectEntity, RestoreEntity): """Representation of a select input.""" + _entity_component_unrecorded_attributes = ( + SelectEntity._entity_component_unrecorded_attributes - {ATTR_OPTIONS} + ) + _unrecorded_attributes = frozenset({ATTR_EDITABLE}) + _attr_should_poll = False editable: bool diff --git a/homeassistant/components/input_select/recorder.py b/homeassistant/components/input_select/recorder.py deleted file mode 100644 index 8e94dc93f3b6b7..00000000000000 --- a/homeassistant/components/input_select/recorder.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.const import ATTR_EDITABLE -from homeassistant.core import HomeAssistant, callback - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude editable hint from being recorded in the database.""" - return {ATTR_EDITABLE} diff --git a/homeassistant/components/input_text/__init__.py b/homeassistant/components/input_text/__init__.py index 096e7cbb10564d..81b75458dc1dcd 100644 --- a/homeassistant/components/input_text/__init__.py +++ b/homeassistant/components/input_text/__init__.py @@ -20,9 +20,6 @@ from homeassistant.helpers import collection import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.helpers.integration_platform import ( - async_process_integration_platform_for_component, -) from homeassistant.helpers.restore_state import RestoreEntity import homeassistant.helpers.service from homeassistant.helpers.storage import Store @@ -110,10 +107,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up an input text.""" component = EntityComponent[InputText](_LOGGER, DOMAIN, hass) - # Process integration platforms right away since - # we will create entities before firing EVENT_COMPONENT_LOADED - await async_process_integration_platform_for_component(hass, DOMAIN) - id_manager = collection.IDManager() yaml_collection = collection.YamlCollection( @@ -187,6 +180,10 @@ async def _update_data(self, item: dict, update_data: dict) -> dict: class InputText(collection.CollectionEntity, RestoreEntity): """Represent a text box.""" + _unrecorded_attributes = frozenset( + {ATTR_EDITABLE, ATTR_MAX, ATTR_MIN, ATTR_MODE, ATTR_PATTERN} + ) + _attr_should_poll = False editable: bool diff --git a/homeassistant/components/input_text/recorder.py b/homeassistant/components/input_text/recorder.py deleted file mode 100644 index 0f4969270d00f6..00000000000000 --- a/homeassistant/components/input_text/recorder.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.const import ATTR_EDITABLE -from homeassistant.core import HomeAssistant, callback - -from . import ATTR_MAX, ATTR_MIN, ATTR_MODE, ATTR_PATTERN - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude editable hint from being recorded in the database.""" - return { - ATTR_EDITABLE, - ATTR_MAX, - ATTR_MIN, - ATTR_MODE, - ATTR_PATTERN, - } diff --git a/homeassistant/components/insteon/api/properties.py b/homeassistant/components/insteon/api/properties.py index 7350ab1474347d..80a76e482e51de 100644 --- a/homeassistant/components/insteon/api/properties.py +++ b/homeassistant/components/insteon/api/properties.py @@ -3,7 +3,12 @@ from typing import Any from pyinsteon import devices -from pyinsteon.config import RADIO_BUTTON_GROUPS, RAMP_RATE_IN_SEC, get_usable_value +from pyinsteon.config import ( + LOAD_BUTTON, + RADIO_BUTTON_GROUPS, + RAMP_RATE_IN_SEC, + get_usable_value, +) from pyinsteon.constants import ( RAMP_RATES_SEC, PropertyType, @@ -75,8 +80,11 @@ def get_schema(prop, name, groups): if name == RAMP_RATE_IN_SEC: return _list_schema(name, RAMP_RATE_LIST) if name == RADIO_BUTTON_GROUPS: - button_list = {str(group): groups[group].name for group in groups if group != 1} + button_list = {str(group): groups[group].name for group in groups} return _multi_select_schema(name, button_list) + if name == LOAD_BUTTON: + button_list = {group: groups[group].name for group in groups} + return _list_schema(name, button_list) if prop.value_type == bool: return _bool_schema(name) if prop.value_type == int: diff --git a/homeassistant/components/insteon/ipdb.py b/homeassistant/components/insteon/ipdb.py index de3ba7d55f20fc..9e9f987d6110f7 100644 --- a/homeassistant/components/insteon/ipdb.py +++ b/homeassistant/components/insteon/ipdb.py @@ -10,6 +10,7 @@ DimmableLightingControl_Dial, DimmableLightingControl_DinRail, DimmableLightingControl_FanLinc, + DimmableLightingControl_I3_KeypadLinc_4, DimmableLightingControl_InLineLinc01, DimmableLightingControl_InLineLinc02, DimmableLightingControl_KeypadLinc_6, @@ -55,6 +56,9 @@ DimmableLightingControl_FanLinc: {Platform.LIGHT: [1], Platform.FAN: [2]}, DimmableLightingControl_InLineLinc01: {Platform.LIGHT: [1]}, DimmableLightingControl_InLineLinc02: {Platform.LIGHT: [1]}, + DimmableLightingControl_I3_KeypadLinc_4: { + Platform.LIGHT: [1, 2, 3, 4], + }, DimmableLightingControl_KeypadLinc_6: { Platform.LIGHT: [1], Platform.SWITCH: [3, 4, 5, 6], diff --git a/homeassistant/components/insteon/light.py b/homeassistant/components/insteon/light.py index 1c12bc794f9b48..121d8d62c66aec 100644 --- a/homeassistant/components/insteon/light.py +++ b/homeassistant/components/insteon/light.py @@ -2,6 +2,7 @@ from typing import Any from pyinsteon.config import ON_LEVEL +from pyinsteon.device_types.device_base import Device as InsteonDevice from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity from homeassistant.config_entries import ConfigEntry @@ -51,6 +52,13 @@ class InsteonDimmerEntity(InsteonEntity, LightEntity): _attr_color_mode = ColorMode.BRIGHTNESS _attr_supported_color_modes = {ColorMode.BRIGHTNESS} + def __init__(self, device: InsteonDevice, group: int) -> None: + """Init the InsteonDimmerEntity entity.""" + super().__init__(device=device, group=group) + if not self._insteon_device_group.is_dimmable: + self._attr_color_mode = ColorMode.ONOFF + self._attr_supported_color_modes = {ColorMode.ONOFF} + @property def brightness(self): """Return the brightness of this light between 0..255.""" diff --git a/homeassistant/components/insteon/manifest.json b/homeassistant/components/insteon/manifest.json index ad3fb7bfbe81fd..5fa45a16fb603b 100644 --- a/homeassistant/components/insteon/manifest.json +++ b/homeassistant/components/insteon/manifest.json @@ -17,8 +17,8 @@ "iot_class": "local_push", "loggers": ["pyinsteon", "pypubsub"], "requirements": [ - "pyinsteon==1.4.3", - "insteon-frontend-home-assistant==0.3.5" + "pyinsteon==1.5.1", + "insteon-frontend-home-assistant==0.4.0" ], "usb": [ { diff --git a/homeassistant/components/ios/sensor.py b/homeassistant/components/ios/sensor.py index 45cd3586af2c92..610cea8c814a7a 100644 --- a/homeassistant/components/ios/sensor.py +++ b/homeassistant/components/ios/sensor.py @@ -137,7 +137,7 @@ def _update(self, device): self.async_write_ha_state() async def async_added_to_hass(self) -> None: - """Added to hass so need to register to dispatch.""" + """Handle addition to hass: register to dispatch.""" self._attr_native_value = self._device[ios.ATTR_BATTERY][ self.entity_description.key ] diff --git a/homeassistant/components/ipma/weather.py b/homeassistant/components/ipma/weather.py index a5bb398157582e..f9b93cbe954142 100644 --- a/homeassistant/components/ipma/weather.py +++ b/homeassistant/components/ipma/weather.py @@ -103,10 +103,7 @@ async def async_update(self) -> None: else: self._daily_forecast = None - if self._period == 1 or self._forecast_listeners["hourly"]: - await self._update_forecast("hourly", 1, True) - else: - self._hourly_forecast = None + await self._update_forecast("hourly", 1, True) _LOGGER.debug( "Updated location %s based on %s, current observation %s", @@ -139,8 +136,8 @@ def _condition_conversion(self, identifier, forecast_dt): @property def condition(self): - """Return the current condition.""" - forecast = self._hourly_forecast or self._daily_forecast + """Return the current condition which is only available on the hourly forecast data.""" + forecast = self._hourly_forecast if not forecast: return diff --git a/homeassistant/components/ipp/config_flow.py b/homeassistant/components/ipp/config_flow.py index 8d1da6eca91adb..dfe6c0b2127f19 100644 --- a/homeassistant/components/ipp/config_flow.py +++ b/homeassistant/components/ipp/config_flow.py @@ -116,8 +116,7 @@ async def async_step_zeroconf( name = discovery_info.name.replace(f".{zctype}", "") tls = zctype == "_ipps._tcp.local." base_path = discovery_info.properties.get("rp", "ipp/print") - - self.context.update({"title_placeholders": {"name": name}}) + unique_id = discovery_info.properties.get("UUID") self.discovery_info.update( { @@ -127,10 +126,18 @@ async def async_step_zeroconf( CONF_VERIFY_SSL: False, CONF_BASE_PATH: f"/{base_path}", CONF_NAME: name, - CONF_UUID: discovery_info.properties.get("UUID"), + CONF_UUID: unique_id, } ) + if unique_id: + # If we already have the unique id, try to set it now + # so we can avoid probing the device if its already + # configured or ignored + await self._async_set_unique_id_and_abort_if_already_configured(unique_id) + + self.context.update({"title_placeholders": {"name": name}}) + try: info = await validate_input(self.hass, self.discovery_info) except IPPConnectionUpgradeRequired: @@ -147,7 +154,6 @@ async def async_step_zeroconf( _LOGGER.debug("IPP Error", exc_info=True) return self.async_abort(reason="ipp_error") - unique_id = self.discovery_info[CONF_UUID] if not unique_id and info[CONF_UUID]: _LOGGER.debug( "Printer UUID is missing from discovery info. Falling back to IPP UUID" @@ -164,18 +170,24 @@ async def async_step_zeroconf( "Unable to determine unique id from discovery info and IPP response" ) - if unique_id: - await self.async_set_unique_id(unique_id) - self._abort_if_unique_id_configured( - updates={ - CONF_HOST: self.discovery_info[CONF_HOST], - CONF_NAME: self.discovery_info[CONF_NAME], - }, - ) + if unique_id and self.unique_id != unique_id: + await self._async_set_unique_id_and_abort_if_already_configured(unique_id) await self._async_handle_discovery_without_unique_id() return await self.async_step_zeroconf_confirm() + async def _async_set_unique_id_and_abort_if_already_configured( + self, unique_id: str + ) -> None: + """Set the unique ID and abort if already configured.""" + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured( + updates={ + CONF_HOST: self.discovery_info[CONF_HOST], + CONF_NAME: self.discovery_info[CONF_NAME], + }, + ) + async def async_step_zeroconf_confirm( self, user_input: dict[str, Any] | None = None ) -> FlowResult: diff --git a/homeassistant/components/iqvia/manifest.json b/homeassistant/components/iqvia/manifest.json index 315d063d6aa7fc..ce519de1b67039 100644 --- a/homeassistant/components/iqvia/manifest.json +++ b/homeassistant/components/iqvia/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["pyiqvia"], - "requirements": ["numpy==1.23.2", "pyiqvia==2022.04.0"] + "requirements": ["numpy==1.26.0", "pyiqvia==2022.04.0"] } diff --git a/homeassistant/components/islamic_prayer_times/config_flow.py b/homeassistant/components/islamic_prayer_times/config_flow.py index d0d314fe67d223..333b6b36c87ba4 100644 --- a/homeassistant/components/islamic_prayer_times/config_flow.py +++ b/homeassistant/components/islamic_prayer_times/config_flow.py @@ -8,8 +8,28 @@ from homeassistant import config_entries from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult - -from .const import CALC_METHODS, CONF_CALC_METHOD, DEFAULT_CALC_METHOD, DOMAIN, NAME +from homeassistant.helpers.selector import ( + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) + +from .const import ( + CALC_METHODS, + CONF_CALC_METHOD, + CONF_LAT_ADJ_METHOD, + CONF_MIDNIGHT_MODE, + CONF_SCHOOL, + DEFAULT_CALC_METHOD, + DEFAULT_LAT_ADJ_METHOD, + DEFAULT_MIDNIGHT_MODE, + DEFAULT_SCHOOL, + DOMAIN, + LAT_ADJ_METHODS, + MIDNIGHT_MODES, + NAME, + SCHOOLS, +) class IslamicPrayerFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): @@ -58,7 +78,47 @@ async def async_step_init( default=self.config_entry.options.get( CONF_CALC_METHOD, DEFAULT_CALC_METHOD ), - ): vol.In(CALC_METHODS) + ): SelectSelector( + SelectSelectorConfig( + options=CALC_METHODS, + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_CALC_METHOD, + ) + ), + vol.Optional( + CONF_LAT_ADJ_METHOD, + default=self.config_entry.options.get( + CONF_LAT_ADJ_METHOD, DEFAULT_LAT_ADJ_METHOD + ), + ): SelectSelector( + SelectSelectorConfig( + options=LAT_ADJ_METHODS, + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_LAT_ADJ_METHOD, + ) + ), + vol.Optional( + CONF_MIDNIGHT_MODE, + default=self.config_entry.options.get( + CONF_MIDNIGHT_MODE, DEFAULT_MIDNIGHT_MODE + ), + ): SelectSelector( + SelectSelectorConfig( + options=MIDNIGHT_MODES, + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_MIDNIGHT_MODE, + ) + ), + vol.Optional( + CONF_SCHOOL, + default=self.config_entry.options.get(CONF_SCHOOL, DEFAULT_SCHOOL), + ): SelectSelector( + SelectSelectorConfig( + options=SCHOOLS, + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_SCHOOL, + ) + ), } return self.async_show_form(step_id="init", data_schema=vol.Schema(options)) diff --git a/homeassistant/components/islamic_prayer_times/const.py b/homeassistant/components/islamic_prayer_times/const.py index 2a73a33bef8029..926651738a2ecb 100644 --- a/homeassistant/components/islamic_prayer_times/const.py +++ b/homeassistant/components/islamic_prayer_times/const.py @@ -1,12 +1,39 @@ """Constants for the Islamic Prayer component.""" from typing import Final -from prayer_times_calculator import PrayerTimesCalculator - DOMAIN: Final = "islamic_prayer_times" NAME: Final = "Islamic Prayer Times" CONF_CALC_METHOD: Final = "calculation_method" -CALC_METHODS: list[str] = list(PrayerTimesCalculator.CALCULATION_METHODS) +CALC_METHODS: Final = [ + "jafari", + "karachi", + "isna", + "mwl", + "makkah", + "egypt", + "tehran", + "gulf", + "kuwait", + "qatar", + "singapore", + "france", + "turkey", + "russia", + "moonsighting", + "custom", +] DEFAULT_CALC_METHOD: Final = "isna" + +CONF_LAT_ADJ_METHOD: Final = "latitude_adjustment_method" +LAT_ADJ_METHODS: Final = ["middle_of_the_night", "one_seventh", "angle_based"] +DEFAULT_LAT_ADJ_METHOD: Final = "middle_of_the_night" + +CONF_MIDNIGHT_MODE: Final = "midnight_mode" +MIDNIGHT_MODES: Final = ["standard", "jafari"] +DEFAULT_MIDNIGHT_MODE: Final = "standard" + +CONF_SCHOOL: Final = "school" +SCHOOLS: Final = ["shafi", "hanafi"] +DEFAULT_SCHOOL: Final = "shafi" diff --git a/homeassistant/components/islamic_prayer_times/coordinator.py b/homeassistant/components/islamic_prayer_times/coordinator.py index 1a8b0bf70364b5..161ce7b26448b0 100644 --- a/homeassistant/components/islamic_prayer_times/coordinator.py +++ b/homeassistant/components/islamic_prayer_times/coordinator.py @@ -3,6 +3,7 @@ from datetime import datetime, timedelta import logging +from typing import Any, cast from prayer_times_calculator import PrayerTimesCalculator, exceptions from requests.exceptions import ConnectionError as ConnError @@ -13,7 +14,17 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed import homeassistant.util.dt as dt_util -from .const import CONF_CALC_METHOD, DEFAULT_CALC_METHOD, DOMAIN +from .const import ( + CONF_CALC_METHOD, + CONF_LAT_ADJ_METHOD, + CONF_MIDNIGHT_MODE, + CONF_SCHOOL, + DEFAULT_CALC_METHOD, + DEFAULT_LAT_ADJ_METHOD, + DEFAULT_MIDNIGHT_MODE, + DEFAULT_SCHOOL, + DOMAIN, +) _LOGGER = logging.getLogger(__name__) @@ -37,15 +48,37 @@ def calc_method(self) -> str: """Return the calculation method.""" return self.config_entry.options.get(CONF_CALC_METHOD, DEFAULT_CALC_METHOD) - def get_new_prayer_times(self) -> dict[str, str]: + @property + def lat_adj_method(self) -> str: + """Return the latitude adjustment method.""" + return str( + self.config_entry.options.get( + CONF_LAT_ADJ_METHOD, DEFAULT_LAT_ADJ_METHOD + ).replace("_", " ") + ) + + @property + def midnight_mode(self) -> str: + """Return the midnight mode.""" + return self.config_entry.options.get(CONF_MIDNIGHT_MODE, DEFAULT_MIDNIGHT_MODE) + + @property + def school(self) -> str: + """Return the school.""" + return self.config_entry.options.get(CONF_SCHOOL, DEFAULT_SCHOOL) + + def get_new_prayer_times(self) -> dict[str, Any]: """Fetch prayer times for today.""" calc = PrayerTimesCalculator( latitude=self.hass.config.latitude, longitude=self.hass.config.longitude, calculation_method=self.calc_method, + latitudeAdjustmentMethod=self.lat_adj_method, + midnightMode=self.midnight_mode, + school=self.school, date=str(dt_util.now().date()), ) - return calc.fetch_prayer_times() + return cast(dict[str, Any], calc.fetch_prayer_times()) @callback def async_schedule_future_update(self, midnight_dt: datetime) -> None: @@ -98,7 +131,7 @@ def async_schedule_future_update(self, midnight_dt: datetime) -> None: self.hass, self.async_request_update, next_update_at ) - async def async_request_update(self, *_) -> None: + async def async_request_update(self, _: datetime) -> None: """Request update from coordinator.""" await self.async_request_refresh() diff --git a/homeassistant/components/islamic_prayer_times/strings.json b/homeassistant/components/islamic_prayer_times/strings.json index 7c09cc605bdf01..e07a38ca1072a8 100644 --- a/homeassistant/components/islamic_prayer_times/strings.json +++ b/homeassistant/components/islamic_prayer_times/strings.json @@ -15,11 +15,55 @@ "step": { "init": { "data": { - "calculation_method": "Prayer calculation method" + "calculation_method": "Prayer calculation method", + "latitude_adjustment_method": "Latitude adjustment method", + "midnight_mode": "Midnight mode", + "school": "School" } } } }, + "selector": { + "calculation_method": { + "options": { + "jafari": "Shia Ithna-Ansari", + "karachi": "University of Islamic Sciences, Karachi", + "isna": "Islamic Society of North America", + "mwl": "Muslim World League", + "makkah": "Umm Al-Qura University, Makkah", + "egypt": "Egyptian General Authority of Survey", + "tehran": "Institute of Geophysics, University of Tehran", + "gulf": "Gulf Region", + "kuwait": "Kuwait", + "qatar": "Qatar", + "singapore": "Majlis Ugama Islam Singapura, Singapore", + "france": "Union Organization islamic de France", + "turkey": "Diyanet İşleri Başkanlığı, Turkey", + "russia": "Spiritual Administration of Muslims of Russia", + "moonsighting": "Moonsighting Committee Worldwide", + "custom": "Custom" + } + }, + "latitude_adjustment_method": { + "options": { + "middle_of_the_night": "Middle of the night", + "one_seventh": "One seventh", + "angle_based": "Angle based" + } + }, + "midnight_mode": { + "options": { + "standard": "Standard (mid sunset to sunrise)", + "jafari": "Jafari (mid sunset to fajr)" + } + }, + "school": { + "options": { + "shafi": "Shafi", + "hanafi": "Hanafi" + } + } + }, "entity": { "sensor": { "fajr": { diff --git a/homeassistant/components/isy994/binary_sensor.py b/homeassistant/components/isy994/binary_sensor.py index aa7c3d551473bf..7be3b87a0d396f 100644 --- a/homeassistant/components/isy994/binary_sensor.py +++ b/homeassistant/components/isy994/binary_sensor.py @@ -23,9 +23,8 @@ from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import async_track_point_in_utc_time +from homeassistant.helpers.event import async_call_later from homeassistant.helpers.restore_state import RestoreEntity -from homeassistant.util import dt as dt_util from .const import ( _LOGGER, @@ -250,7 +249,8 @@ def __init__( ) -> None: """Initialize the ISY binary sensor device.""" super().__init__(node, device_info=device_info) - self._device_class = force_device_class + # This was discovered by parsing the device type code during init + self._attr_device_class = force_device_class @property def is_on(self) -> bool | None: @@ -259,14 +259,6 @@ def is_on(self) -> bool | None: return None return bool(self._node.status) - @property - def device_class(self) -> BinarySensorDeviceClass | None: - """Return the class of this device. - - This was discovered by parsing the device type code during init - """ - return self._device_class - class ISYInsteonBinarySensorEntity(ISYBinarySensorEntity): """Representation of an ISY Insteon binary sensor device. @@ -496,15 +488,8 @@ def timer_elapsed(now: datetime) -> None: self._heartbeat_timer = None self.async_write_ha_state() - point_in_time = dt_util.utcnow() + timedelta(hours=25) - _LOGGER.debug( - "Heartbeat timer starting. Now: %s Then: %s", - dt_util.utcnow(), - point_in_time, - ) - - self._heartbeat_timer = async_track_point_in_utc_time( - self.hass, timer_elapsed, point_in_time + self._heartbeat_timer = async_call_later( + self.hass, timedelta(hours=25), timer_elapsed ) @callback diff --git a/homeassistant/components/isy994/sensor.py b/homeassistant/components/isy994/sensor.py index b1899100dd4491..1a160024a65e48 100644 --- a/homeassistant/components/isy994/sensor.py +++ b/homeassistant/components/isy994/sensor.py @@ -262,6 +262,7 @@ def target_value(self) -> Any: """Return the target value.""" return None if self.target is None else self.target.value + # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: """Subscribe to the node control change events. diff --git a/homeassistant/components/isy994/switch.py b/homeassistant/components/isy994/switch.py index 8467cba9e6a4ed..de64741ba3a295 100644 --- a/homeassistant/components/isy994/switch.py +++ b/homeassistant/components/isy994/switch.py @@ -156,6 +156,7 @@ def __init__( self._attr_name = description.name # Override super self._change_handler: EventListener = None + # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: """Subscribe to the node control change events.""" self._change_handler = self._node.isy.nodes.status_events.subscribe( diff --git a/homeassistant/components/juicenet/entity.py b/homeassistant/components/juicenet/entity.py index 3c325715c8245e..b3433948582819 100644 --- a/homeassistant/components/juicenet/entity.py +++ b/homeassistant/components/juicenet/entity.py @@ -23,20 +23,12 @@ def __init__( super().__init__(coordinator) self.device = device self.key = key - - @property - def unique_id(self): - """Return a unique ID.""" - return f"{self.device.id}-{self.key}" - - @property - def device_info(self) -> DeviceInfo: - """Return device information about this JuiceNet Device.""" - return DeviceInfo( + self._attr_unique_id = f"{device.id}-{key}" + self._attr_device_info = DeviceInfo( configuration_url=( - f"https://home.juice.net/Portal/Details?unitID={self.device.id}" + f"https://home.juice.net/Portal/Details?unitID={device.id}" ), - identifiers={(DOMAIN, self.device.id)}, + identifiers={(DOMAIN, device.id)}, manufacturer="JuiceNet", - name=self.device.name, + name=device.name, ) diff --git a/homeassistant/components/jvc_projector/strings.json b/homeassistant/components/jvc_projector/strings.json index 1f85c20fc72222..6fdc5b4d12f560 100644 --- a/homeassistant/components/jvc_projector/strings.json +++ b/homeassistant/components/jvc_projector/strings.json @@ -29,7 +29,7 @@ "error": { "invalid_host": "[%key:common::config_flow::error::invalid_host%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "invalid_auth": "[%key:component::jvc_projector::config::step::reauth_confirm::description%]" + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" } } } diff --git a/homeassistant/components/keenetic_ndms2/binary_sensor.py b/homeassistant/components/keenetic_ndms2/binary_sensor.py index f39c92519e432c..ab0b33701976e8 100644 --- a/homeassistant/components/keenetic_ndms2/binary_sensor.py +++ b/homeassistant/components/keenetic_ndms2/binary_sensor.py @@ -33,22 +33,14 @@ class RouterOnlineBinarySensor(BinarySensorEntity): def __init__(self, router: KeeneticRouter) -> None: """Initialize the APCUPSd binary device.""" self._router = router - - @property - def unique_id(self) -> str: - """Return a unique identifier for this device.""" - return f"online_{self._router.config_entry.entry_id}" + self._attr_unique_id = f"online_{router.config_entry.entry_id}" + self._attr_device_info = router.device_info @property def is_on(self): """Return true if the UPS is online, else false.""" return self._router.available - @property - def device_info(self): - """Return a client description for device registry.""" - return self._router.device_info - async def async_added_to_hass(self) -> None: """Client entity created.""" self.async_on_remove( diff --git a/homeassistant/components/kodi/media_player.py b/homeassistant/components/kodi/media_player.py index 9c69abc08c8159..32ecbbed6260a2 100644 --- a/homeassistant/components/kodi/media_player.py +++ b/homeassistant/components/kodi/media_player.py @@ -282,7 +282,7 @@ def __init__(self, connection, kodi, name, uid): """Initialize the Kodi entity.""" self._connection = connection self._kodi = kodi - self._unique_id = uid + self._attr_unique_id = uid self._device_id = None self._players = None self._properties = {} @@ -369,11 +369,6 @@ async def _clear_connection(self, close=True): if close: await self._connection.close() - @property - def unique_id(self): - """Return the unique id of the device.""" - return self._unique_id - @property def state(self) -> MediaPlayerState: """Return the state of the device.""" diff --git a/homeassistant/components/kodi/strings.json b/homeassistant/components/kodi/strings.json index f7ee375f9902f0..51431b317d6488 100644 --- a/homeassistant/components/kodi/strings.json +++ b/homeassistant/components/kodi/strings.json @@ -43,8 +43,8 @@ }, "device_automation": { "trigger_type": { - "turn_on": "[%key:common::device_automation::action_type::turn_on%]", - "turn_off": "[%key:common::device_automation::action_type::turn_off%]" + "turn_on": "[%key:common::device_automation::trigger_type::turned_on%]", + "turn_off": "[%key:common::device_automation::trigger_type::turned_off%]" } }, "services": { diff --git a/homeassistant/components/konnected/binary_sensor.py b/homeassistant/components/konnected/binary_sensor.py index 2f21f8c15bda89..d7c41337342c1b 100644 --- a/homeassistant/components/konnected/binary_sensor.py +++ b/homeassistant/components/konnected/binary_sensor.py @@ -42,38 +42,12 @@ class KonnectedBinarySensor(BinarySensorEntity): def __init__(self, device_id, zone_num, data): """Initialize the Konnected binary sensor.""" self._data = data - self._device_id = device_id - self._zone_num = zone_num - self._state = self._data.get(ATTR_STATE) - self._device_class = self._data.get(CONF_TYPE) - self._unique_id = f"{device_id}-{zone_num}" - self._name = self._data.get(CONF_NAME) - - @property - def unique_id(self) -> str: - """Return the unique id.""" - return self._unique_id - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def is_on(self): - """Return the state of the sensor.""" - return self._state - - @property - def device_class(self): - """Return the device class.""" - return self._device_class - - @property - def device_info(self) -> DeviceInfo: - """Return the device info.""" - return DeviceInfo( - identifiers={(KONNECTED_DOMAIN, self._device_id)}, + self._attr_is_on = data.get(ATTR_STATE) + self._attr_device_class = data.get(CONF_TYPE) + 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)}, ) async def async_added_to_hass(self) -> None: @@ -88,5 +62,5 @@ async def async_added_to_hass(self) -> None: @callback def async_set_state(self, state): """Update the sensor's state.""" - self._state = state + self._attr_is_on = state self.async_write_ha_state() diff --git a/homeassistant/components/konnected/sensor.py b/homeassistant/components/konnected/sensor.py index b341afa765f0ef..3f203d5f3e8240 100644 --- a/homeassistant/components/konnected/sensor.py +++ b/homeassistant/components/konnected/sensor.py @@ -111,9 +111,9 @@ def __init__( self._attr_unique_id = addr or f"{device_id}-{self._zone_num}-{description.key}" # set initial state if known at initialization - self._state = initial_state - if self._state: - self._state = round(float(self._state), 1) + self._attr_native_value = initial_state + if initial_state: + self._attr_native_value = round(float(initial_state), 1) # set entity name if given if name := self._data.get(CONF_NAME): @@ -122,11 +122,6 @@ def __init__( self._attr_device_info = DeviceInfo(identifiers={(KONNECTED_DOMAIN, device_id)}) - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state - async def async_added_to_hass(self) -> None: """Store entity_id and register state change callback.""" entity_id_key = self._addr or self.entity_description.key @@ -139,7 +134,7 @@ async def async_added_to_hass(self) -> None: def async_set_state(self, state): """Update the sensor's state.""" if self.entity_description.key == "humidity": - self._state = int(float(state)) + self._attr_native_value = int(float(state)) else: - self._state = round(float(state), 1) + self._attr_native_value = round(float(state), 1) self.async_write_ha_state() diff --git a/homeassistant/components/konnected/switch.py b/homeassistant/components/konnected/switch.py index ba0dc62b60613f..18132a913add16 100644 --- a/homeassistant/components/konnected/switch.py +++ b/homeassistant/components/konnected/switch.py @@ -56,27 +56,13 @@ def __init__(self, device_id, zone_num, data): self._momentary = self._data.get(CONF_MOMENTARY) self._pause = self._data.get(CONF_PAUSE) self._repeat = self._data.get(CONF_REPEAT) - self._state = self._boolean_state(self._data.get(ATTR_STATE)) - self._name = self._data.get(CONF_NAME) - self._unique_id = ( + self._attr_is_on = self._boolean_state(self._data.get(ATTR_STATE)) + self._attr_name = self._data.get(CONF_NAME) + self._attr_unique_id = ( f"{device_id}-{self._zone_num}-{self._momentary}-" f"{self._pause}-{self._repeat}" ) - - @property - def unique_id(self) -> str: - """Return the unique id.""" - return self._unique_id - - @property - def name(self): - """Return the name of the switch.""" - return self._name - - @property - def is_on(self): - """Return the status of the sensor.""" - return self._state + self._attr_device_info = DeviceInfo(identifiers={(KONNECTED_DOMAIN, device_id)}) @property def panel(self): @@ -84,11 +70,6 @@ def panel(self): device_data = self.hass.data[KONNECTED_DOMAIN][CONF_DEVICES][self._device_id] return device_data.get("panel") - @property - def device_info(self) -> DeviceInfo: - """Return the device info.""" - return DeviceInfo(identifiers={(KONNECTED_DOMAIN, self._device_id)}) - @property def available(self) -> bool: """Return whether the panel is available.""" @@ -129,7 +110,7 @@ def _boolean_state(self, int_state): return self._activation == STATE_HIGH def _set_state(self, state): - self._state = state + self._attr_is_on = state self.async_write_ha_state() _LOGGER.debug( "Setting status of %s actuator zone %s to %s", diff --git a/homeassistant/components/kostal_plenticore/sensor.py b/homeassistant/components/kostal_plenticore/sensor.py index 78ab609aa16f59..f7bad638df4ff2 100644 --- a/homeassistant/components/kostal_plenticore/sensor.py +++ b/homeassistant/components/kostal_plenticore/sensor.py @@ -745,16 +745,16 @@ def __init__( super().__init__(coordinator) self.entity_description = description self.entry_id = entry_id - self.platform_name = platform_name self.module_id = description.module_id self.data_id = description.key - self._sensor_name = description.name self._formatter: Callable[[str], Any] = PlenticoreDataFormatter.get_method( description.formatter ) - self._device_info = device_info + self._attr_device_info = device_info + self._attr_unique_id = f"{entry_id}_{self.module_id}_{self.data_id}" + self._attr_name = f"{platform_name} {description.name}" @property def available(self) -> bool: @@ -778,21 +778,6 @@ async def async_will_remove_from_hass(self) -> None: self.coordinator.stop_fetch_data(self.module_id, self.data_id) await super().async_will_remove_from_hass() - @property - def device_info(self) -> DeviceInfo: - """Return the device info.""" - return self._device_info - - @property - def unique_id(self) -> str: - """Return the unique id of this Sensor Entity.""" - return f"{self.entry_id}_{self.module_id}_{self.data_id}" - - @property - def name(self) -> str: - """Return the name of this Sensor Entity.""" - return f"{self.platform_name} {self._sensor_name}" - @property def native_value(self) -> StateType: """Return the state of the sensor.""" diff --git a/homeassistant/components/kostal_plenticore/switch.py b/homeassistant/components/kostal_plenticore/switch.py index 574368b432f78a..554f8db2b68eb8 100644 --- a/homeassistant/components/kostal_plenticore/switch.py +++ b/homeassistant/components/kostal_plenticore/switch.py @@ -116,7 +116,6 @@ def __init__( """Create a new Switch Entity for Plenticore process data.""" super().__init__(coordinator) self.entity_description = description - self.entry_id = entry_id self.platform_name = platform_name self.module_id = description.module_id self.data_id = description.key @@ -129,7 +128,7 @@ def __init__( self.off_label = description.off_label self._attr_unique_id = f"{entry_id}_{description.module_id}_{description.key}" - self._device_info = device_info + self._attr_device_info = device_info @property def available(self) -> bool: @@ -171,11 +170,6 @@ async def async_turn_off(self, **kwargs: Any) -> None: ) await self.coordinator.async_request_refresh() - @property - def device_info(self) -> DeviceInfo: - """Return the device info.""" - return self._device_info - @property def is_on(self) -> bool: """Return true if device is on.""" diff --git a/homeassistant/components/kulersky/light.py b/homeassistant/components/kulersky/light.py index c68633ab639692..6636bfdba9f33c 100644 --- a/homeassistant/components/kulersky/light.py +++ b/homeassistant/components/kulersky/light.py @@ -66,13 +66,19 @@ class KulerskyLight(LightEntity): _attr_has_entity_name = True _attr_name = None + _attr_available = False + _attr_supported_color_modes = {ColorMode.RGBW} + _attr_color_mode = ColorMode.RGBW def __init__(self, light: pykulersky.Light) -> None: """Initialize a Kuler Sky light.""" self._light = light - self._available = False - self._attr_supported_color_modes = {ColorMode.RGBW} - self._attr_color_mode = ColorMode.RGBW + self._attr_unique_id = light.address + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, light.address)}, + manufacturer="Brightech", + name=light.name, + ) async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" @@ -91,30 +97,11 @@ async def async_will_remove_from_hass(self, *args) -> None: "Exception disconnected from %s", self._light.address, exc_info=True ) - @property - def unique_id(self): - """Return the ID of this light.""" - return self._light.address - - @property - def device_info(self) -> DeviceInfo: - """Device info for this light.""" - return DeviceInfo( - identifiers={(DOMAIN, self.unique_id)}, - manufacturer="Brightech", - name=self._light.name, - ) - @property def is_on(self): """Return true if light is on.""" return self.brightness > 0 - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self._available - async def async_turn_on(self, **kwargs: Any) -> None: """Instruct the light to turn on.""" default_rgbw = (255,) * 4 if self.rgbw_color is None else self.rgbw_color @@ -140,18 +127,18 @@ async def async_turn_off(self, **kwargs: Any) -> None: async def async_update(self) -> None: """Fetch new state data for this light.""" try: - if not self._available: + if not self._attr_available: await self._light.connect() rgbw = await self._light.get_color() except pykulersky.PykulerskyException as exc: - if self._available: + if self._attr_available: _LOGGER.warning("Unable to connect to %s: %s", self._light.address, exc) - self._available = False + self._attr_available = False return - if self._available is False: + if self._attr_available is False: _LOGGER.info("Reconnected to %s", self._light.address) - self._available = True + self._attr_available = True brightness = max(rgbw) if not brightness: self._attr_rgbw_color = (0, 0, 0, 0) diff --git a/homeassistant/components/landisgyr_heat_meter/manifest.json b/homeassistant/components/landisgyr_heat_meter/manifest.json index a056f1f65645fa..1bf77d7ab5166c 100644 --- a/homeassistant/components/landisgyr_heat_meter/manifest.json +++ b/homeassistant/components/landisgyr_heat_meter/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["usb"], "documentation": "https://www.home-assistant.io/integrations/landisgyr_heat_meter", "iot_class": "local_polling", - "requirements": ["ultraheat-api==0.5.1"] + "requirements": ["ultraheat-api==0.5.7"] } diff --git a/homeassistant/components/laundrify/binary_sensor.py b/homeassistant/components/laundrify/binary_sensor.py index 81882b68f006fd..5cca6870b6c0ff 100644 --- a/homeassistant/components/laundrify/binary_sensor.py +++ b/homeassistant/components/laundrify/binary_sensor.py @@ -51,17 +51,14 @@ def __init__( """Pass coordinator to CoordinatorEntity.""" super().__init__(coordinator) self._device = device - self._attr_unique_id = device["_id"] - - @property - def device_info(self) -> DeviceInfo: - """Configure the Device of this Entity.""" - return DeviceInfo( - identifiers={(DOMAIN, self._device["_id"])}, - name=self._device["name"], + unique_id = device["_id"] + self._attr_unique_id = unique_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + name=device["name"], manufacturer=MANUFACTURER, model=MODEL, - sw_version=self._device["firmwareVersion"], + sw_version=device["firmwareVersion"], ) @property diff --git a/homeassistant/components/lawn_mower/strings.json b/homeassistant/components/lawn_mower/strings.json index caf2e15df779c2..15ed50ca6c5cd3 100644 --- a/homeassistant/components/lawn_mower/strings.json +++ b/homeassistant/components/lawn_mower/strings.json @@ -5,7 +5,7 @@ "name": "[%key:component::lawn_mower::title%]", "state": { "error": "Error", - "paused": "Paused", + "paused": "[%key:common::state::paused%]", "mowing": "Mowing", "docked": "Docked" } diff --git a/homeassistant/components/lcn/binary_sensor.py b/homeassistant/components/lcn/binary_sensor.py index 13a2a5b3bb32fc..ceeeecf50c4e83 100644 --- a/homeassistant/components/lcn/binary_sensor.py +++ b/homeassistant/components/lcn/binary_sensor.py @@ -66,8 +66,6 @@ def __init__( config[CONF_DOMAIN_DATA][CONF_SOURCE] ] - self._value = None - async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" await super().async_added_to_hass() @@ -84,11 +82,6 @@ async def async_will_remove_from_hass(self) -> None: self.setpoint_variable ) - @property - def is_on(self) -> bool | None: - """Return true if the binary sensor is on.""" - return self._value - def input_received(self, input_obj: InputType) -> None: """Set sensor value when LCN input object (command) is received.""" if ( @@ -97,7 +90,7 @@ def input_received(self, input_obj: InputType) -> None: ): return - self._value = input_obj.get_value().is_locked_regulator() + self._attr_is_on = input_obj.get_value().is_locked_regulator() self.async_write_ha_state() @@ -114,8 +107,6 @@ def __init__( config[CONF_DOMAIN_DATA][CONF_SOURCE] ] - self._value = None - async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" await super().async_added_to_hass() @@ -132,17 +123,12 @@ async def async_will_remove_from_hass(self) -> None: self.bin_sensor_port ) - @property - def is_on(self) -> bool | None: - """Return true if the binary sensor is on.""" - return self._value - def input_received(self, input_obj: InputType) -> None: """Set sensor value when LCN input object (command) is received.""" if not isinstance(input_obj, pypck.inputs.ModStatusBinSensors): return - self._value = input_obj.get_state(self.bin_sensor_port.value) + self._attr_is_on = input_obj.get_state(self.bin_sensor_port.value) self.async_write_ha_state() @@ -156,7 +142,6 @@ def __init__( super().__init__(config, entry_id, device_connection) self.source = pypck.lcn_defs.Key[config[CONF_DOMAIN_DATA][CONF_SOURCE]] - self._value = None async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" @@ -170,11 +155,6 @@ async def async_will_remove_from_hass(self) -> None: if not self.device_connection.is_group: await self.device_connection.cancel_status_request_handler(self.source) - @property - def is_on(self) -> bool | None: - """Return true if the binary sensor is on.""" - return self._value - def input_received(self, input_obj: InputType) -> None: """Set sensor value when LCN input object (command) is received.""" if ( @@ -186,5 +166,5 @@ def input_received(self, input_obj: InputType) -> None: table_id = ord(self.source.name[0]) - 65 key_id = int(self.source.name[1]) - 1 - self._value = input_obj.get_state(table_id, key_id) + self._attr_is_on = input_obj.get_state(table_id, key_id) self.async_write_ha_state() diff --git a/homeassistant/components/lcn/cover.py b/homeassistant/components/lcn/cover.py index bc83da55888244..31b2dbface02c5 100644 --- a/homeassistant/components/lcn/cover.py +++ b/homeassistant/components/lcn/cover.py @@ -51,6 +51,11 @@ async def async_setup_entry( class LcnOutputsCover(LcnEntity, CoverEntity): """Representation of a LCN cover connected to output ports.""" + _attr_is_closed = False + _attr_is_closing = False + _attr_is_opening = False + _attr_assumed_state = True + def __init__( self, config: ConfigType, entry_id: str, device_connection: DeviceConnectionType ) -> None: @@ -68,10 +73,6 @@ def __init__( else: self.reverse_time = None - self._is_closed = False - self._is_closing = False - self._is_opening = False - async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" await super().async_added_to_hass() @@ -94,26 +95,6 @@ async def async_will_remove_from_hass(self) -> None: pypck.lcn_defs.OutputPort["OUTPUTDOWN"] ) - @property - def is_closed(self) -> bool: - """Return if the cover is closed.""" - return self._is_closed - - @property - def is_opening(self) -> bool: - """Return if the cover is opening or not.""" - return self._is_opening - - @property - def is_closing(self) -> bool: - """Return if the cover is closing or not.""" - return self._is_closing - - @property - def assumed_state(self) -> bool: - """Return True if unable to access real state of the entity.""" - return True - async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" state = pypck.lcn_defs.MotorStateModifier.DOWN @@ -121,8 +102,8 @@ async def async_close_cover(self, **kwargs: Any) -> None: state, self.reverse_time ): return - self._is_opening = False - self._is_closing = True + self._attr_is_opening = False + self._attr_is_closing = True self.async_write_ha_state() async def async_open_cover(self, **kwargs: Any) -> None: @@ -132,9 +113,9 @@ async def async_open_cover(self, **kwargs: Any) -> None: state, self.reverse_time ): return - self._is_closed = False - self._is_opening = True - self._is_closing = False + self._attr_is_closed = False + self._attr_is_opening = True + self._attr_is_closing = False self.async_write_ha_state() async def async_stop_cover(self, **kwargs: Any) -> None: @@ -142,8 +123,8 @@ async def async_stop_cover(self, **kwargs: Any) -> None: state = pypck.lcn_defs.MotorStateModifier.STOP if not await self.device_connection.control_motors_outputs(state): return - self._is_closing = False - self._is_opening = False + self._attr_is_closing = False + self._attr_is_opening = False self.async_write_ha_state() def input_received(self, input_obj: InputType) -> None: @@ -156,17 +137,17 @@ def input_received(self, input_obj: InputType) -> None: if input_obj.get_percent() > 0: # motor is on if input_obj.get_output_id() == self.output_ids[0]: - self._is_opening = True - self._is_closing = False + self._attr_is_opening = True + self._attr_is_closing = False else: # self.output_ids[1] - self._is_opening = False - self._is_closing = True - self._is_closed = self._is_closing + self._attr_is_opening = False + self._attr_is_closing = True + self._attr_is_closed = self._attr_is_closing else: # motor is off # cover is assumed to be closed if we were in closing state before - self._is_closed = self._is_closing - self._is_closing = False - self._is_opening = False + self._attr_is_closed = self._attr_is_closing + self._attr_is_closing = False + self._attr_is_opening = False self.async_write_ha_state() @@ -174,6 +155,11 @@ def input_received(self, input_obj: InputType) -> None: class LcnRelayCover(LcnEntity, CoverEntity): """Representation of a LCN cover connected to relays.""" + _attr_is_closed = False + _attr_is_closing = False + _attr_is_opening = False + _attr_assumed_state = True + def __init__( self, config: ConfigType, entry_id: str, device_connection: DeviceConnectionType ) -> None: @@ -200,34 +186,14 @@ async def async_will_remove_from_hass(self) -> None: if not self.device_connection.is_group: await self.device_connection.cancel_status_request_handler(self.motor) - @property - def is_closed(self) -> bool: - """Return if the cover is closed.""" - return self._is_closed - - @property - def is_opening(self) -> bool: - """Return if the cover is opening or not.""" - return self._is_opening - - @property - def is_closing(self) -> bool: - """Return if the cover is closing or not.""" - return self._is_closing - - @property - def assumed_state(self) -> bool: - """Return True if unable to access real state of the entity.""" - return True - 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): return - self._is_opening = False - self._is_closing = True + self._attr_is_opening = False + self._attr_is_closing = True self.async_write_ha_state() async def async_open_cover(self, **kwargs: Any) -> None: @@ -236,9 +202,9 @@ async def async_open_cover(self, **kwargs: Any) -> None: states[self.motor.value] = pypck.lcn_defs.MotorStateModifier.UP if not await self.device_connection.control_motors_relays(states): return - self._is_closed = False - self._is_opening = True - self._is_closing = False + self._attr_is_closed = False + self._attr_is_opening = True + self._attr_is_closing = False self.async_write_ha_state() async def async_stop_cover(self, **kwargs: Any) -> None: @@ -247,8 +213,8 @@ async def async_stop_cover(self, **kwargs: Any) -> None: states[self.motor.value] = pypck.lcn_defs.MotorStateModifier.STOP if not await self.device_connection.control_motors_relays(states): return - self._is_closing = False - self._is_opening = False + self._attr_is_closing = False + self._attr_is_opening = False self.async_write_ha_state() def input_received(self, input_obj: InputType) -> None: @@ -258,11 +224,11 @@ def input_received(self, input_obj: InputType) -> None: states = input_obj.states # list of boolean values (relay on/off) if states[self.motor_port_onoff]: # motor is on - self._is_opening = not states[self.motor_port_updown] # set direction - self._is_closing = states[self.motor_port_updown] # set direction + 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._is_opening = False - self._is_closing = False - self._is_closed = states[self.motor_port_updown] + self._attr_is_opening = False + self._attr_is_closing = False + self._attr_is_closed = states[self.motor_port_updown] self.async_write_ha_state() diff --git a/homeassistant/components/lcn/light.py b/homeassistant/components/lcn/light.py index 38480cc3124750..65c1344edf0d91 100644 --- a/homeassistant/components/lcn/light.py +++ b/homeassistant/components/lcn/light.py @@ -65,6 +65,8 @@ class LcnOutputLight(LcnEntity, LightEntity): """Representation of a LCN light for output ports.""" _attr_supported_features = LightEntityFeature.TRANSITION + _attr_is_on = False + _attr_brightness = 255 def __init__( self, config: ConfigType, entry_id: str, device_connection: DeviceConnectionType @@ -79,8 +81,6 @@ def __init__( ) self.dimmable = config[CONF_DOMAIN_DATA][CONF_DIMMABLE] - self._brightness = 255 - self._is_on = False self._is_dimming_to_zero = False if self.dimmable: @@ -101,16 +101,6 @@ async def async_will_remove_from_hass(self) -> None: if not self.device_connection.is_group: await self.device_connection.cancel_status_request_handler(self.output) - @property - def brightness(self) -> int | None: - """Return the brightness of this light between 0..255.""" - return self._brightness - - @property - def is_on(self) -> bool: - """Return True if entity is on.""" - return self._is_on - async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" if ATTR_BRIGHTNESS in kwargs: @@ -128,7 +118,7 @@ async def async_turn_on(self, **kwargs: Any) -> None: self.output.value, percent, transition ): return - self._is_on = True + self._attr_is_on = True self._is_dimming_to_zero = False self.async_write_ha_state() @@ -146,7 +136,7 @@ async def async_turn_off(self, **kwargs: Any) -> None: ): return self._is_dimming_to_zero = bool(transition) - self._is_on = False + self._attr_is_on = False self.async_write_ha_state() def input_received(self, input_obj: InputType) -> None: @@ -157,11 +147,11 @@ def input_received(self, input_obj: InputType) -> None: ): return - self._brightness = int(input_obj.get_percent() / 100.0 * 255) - if self.brightness == 0: + self._attr_brightness = int(input_obj.get_percent() / 100.0 * 255) + if self._attr_brightness == 0: self._is_dimming_to_zero = False - if not self._is_dimming_to_zero and self.brightness is not None: - self._is_on = self.brightness > 0 + if not self._is_dimming_to_zero and self._attr_brightness is not None: + self._attr_is_on = self._attr_brightness > 0 self.async_write_ha_state() @@ -170,6 +160,7 @@ class LcnRelayLight(LcnEntity, LightEntity): _attr_color_mode = ColorMode.ONOFF _attr_supported_color_modes = {ColorMode.ONOFF} + _attr_is_on = False def __init__( self, config: ConfigType, entry_id: str, device_connection: DeviceConnectionType @@ -179,8 +170,6 @@ def __init__( self.output = pypck.lcn_defs.RelayPort[config[CONF_DOMAIN_DATA][CONF_OUTPUT]] - self._is_on = False - async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" await super().async_added_to_hass() @@ -193,18 +182,13 @@ async def async_will_remove_from_hass(self) -> None: if not self.device_connection.is_group: await self.device_connection.cancel_status_request_handler(self.output) - @property - def is_on(self) -> bool: - """Return True if entity is on.""" - return self._is_on - async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" states = [pypck.lcn_defs.RelayStateModifier.NOCHANGE] * 8 states[self.output.value] = pypck.lcn_defs.RelayStateModifier.ON if not await self.device_connection.control_relays(states): return - self._is_on = True + self._attr_is_on = True self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: @@ -213,7 +197,7 @@ async def async_turn_off(self, **kwargs: Any) -> None: states[self.output.value] = pypck.lcn_defs.RelayStateModifier.OFF if not await self.device_connection.control_relays(states): return - self._is_on = False + self._attr_is_on = False self.async_write_ha_state() def input_received(self, input_obj: InputType) -> None: @@ -221,5 +205,5 @@ def input_received(self, input_obj: InputType) -> None: if not isinstance(input_obj, pypck.inputs.ModStatusRelays): return - self._is_on = input_obj.get_state(self.output.value) + self._attr_is_on = input_obj.get_state(self.output.value) self.async_write_ha_state() diff --git a/homeassistant/components/lcn/sensor.py b/homeassistant/components/lcn/sensor.py index 66321c79a1b021..1428019b59f922 100644 --- a/homeassistant/components/lcn/sensor.py +++ b/homeassistant/components/lcn/sensor.py @@ -77,8 +77,7 @@ def __init__( self.unit = pypck.lcn_defs.VarUnit.parse( config[CONF_DOMAIN_DATA][CONF_UNIT_OF_MEASUREMENT] ) - - self._value = None + self._attr_native_unit_of_measurement = cast(str, self.unit.value) async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" @@ -92,16 +91,6 @@ async def async_will_remove_from_hass(self) -> None: if not self.device_connection.is_group: await self.device_connection.cancel_status_request_handler(self.variable) - @property - def native_value(self) -> str | None: - """Return the state of the entity.""" - return self._value - - @property - def native_unit_of_measurement(self) -> str: - """Return the unit of measurement of this entity, if any.""" - return cast(str, self.unit.value) - def input_received(self, input_obj: InputType) -> None: """Set sensor value when LCN input object (command) is received.""" if ( @@ -110,7 +99,7 @@ def input_received(self, input_obj: InputType) -> None: ): return - self._value = input_obj.get_value().to_var_unit(self.unit) + self._attr_native_value = input_obj.get_value().to_var_unit(self.unit) self.async_write_ha_state() @@ -130,8 +119,6 @@ def __init__( config[CONF_DOMAIN_DATA][CONF_SOURCE] ] - self._value = None - async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" await super().async_added_to_hass() @@ -144,19 +131,18 @@ async def async_will_remove_from_hass(self) -> None: if not self.device_connection.is_group: await self.device_connection.cancel_status_request_handler(self.source) - @property - def native_value(self) -> str | None: - """Return the state of the entity.""" - return self._value - def input_received(self, input_obj: InputType) -> None: """Set sensor value when LCN input object (command) is received.""" if not isinstance(input_obj, pypck.inputs.ModStatusLedsAndLogicOps): return if self.source in pypck.lcn_defs.LedPort: - self._value = input_obj.get_led_state(self.source.value).name.lower() + self._attr_native_value = input_obj.get_led_state( + self.source.value + ).name.lower() elif self.source in pypck.lcn_defs.LogicOpPort: - self._value = input_obj.get_logic_op_state(self.source.value).name.lower() + self._attr_native_value = input_obj.get_logic_op_state( + self.source.value + ).name.lower() self.async_write_ha_state() diff --git a/homeassistant/components/lcn/switch.py b/homeassistant/components/lcn/switch.py index ded15c0f1da094..8374ff85ab73ff 100644 --- a/homeassistant/components/lcn/switch.py +++ b/homeassistant/components/lcn/switch.py @@ -52,6 +52,8 @@ async def async_setup_entry( class LcnOutputSwitch(LcnEntity, SwitchEntity): """Representation of a LCN switch for output ports.""" + _attr_is_on = False + def __init__( self, config: ConfigType, entry_id: str, device_connection: DeviceConnectionType ) -> None: @@ -60,8 +62,6 @@ def __init__( self.output = pypck.lcn_defs.OutputPort[config[CONF_DOMAIN_DATA][CONF_OUTPUT]] - self._is_on = False - async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" await super().async_added_to_hass() @@ -74,23 +74,18 @@ async def async_will_remove_from_hass(self) -> None: if not self.device_connection.is_group: await self.device_connection.cancel_status_request_handler(self.output) - @property - def is_on(self) -> bool: - """Return True if entity is on.""" - return self._is_on - async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" if not await self.device_connection.dim_output(self.output.value, 100, 0): return - self._is_on = True + self._attr_is_on = True self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" if not await self.device_connection.dim_output(self.output.value, 0, 0): return - self._is_on = False + self._attr_is_on = False self.async_write_ha_state() def input_received(self, input_obj: InputType) -> None: @@ -101,13 +96,15 @@ def input_received(self, input_obj: InputType) -> None: ): return - self._is_on = input_obj.get_percent() > 0 + self._attr_is_on = input_obj.get_percent() > 0 self.async_write_ha_state() class LcnRelaySwitch(LcnEntity, SwitchEntity): """Representation of a LCN switch for relay ports.""" + _attr_is_on = False + def __init__( self, config: ConfigType, entry_id: str, device_connection: DeviceConnectionType ) -> None: @@ -116,8 +113,6 @@ def __init__( self.output = pypck.lcn_defs.RelayPort[config[CONF_DOMAIN_DATA][CONF_OUTPUT]] - self._is_on = False - async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" await super().async_added_to_hass() @@ -130,18 +125,13 @@ async def async_will_remove_from_hass(self) -> None: if not self.device_connection.is_group: await self.device_connection.cancel_status_request_handler(self.output) - @property - def is_on(self) -> bool: - """Return True if entity is on.""" - return self._is_on - async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" states = [pypck.lcn_defs.RelayStateModifier.NOCHANGE] * 8 states[self.output.value] = pypck.lcn_defs.RelayStateModifier.ON if not await self.device_connection.control_relays(states): return - self._is_on = True + self._attr_is_on = True self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: @@ -150,7 +140,7 @@ async def async_turn_off(self, **kwargs: Any) -> None: states[self.output.value] = pypck.lcn_defs.RelayStateModifier.OFF if not await self.device_connection.control_relays(states): return - self._is_on = False + self._attr_is_on = False self.async_write_ha_state() def input_received(self, input_obj: InputType) -> None: @@ -158,5 +148,5 @@ def input_received(self, input_obj: InputType) -> None: if not isinstance(input_obj, pypck.inputs.ModStatusRelays): return - self._is_on = input_obj.get_state(self.output.value) + self._attr_is_on = input_obj.get_state(self.output.value) self.async_write_ha_state() diff --git a/homeassistant/components/ld2410_ble/manifest.json b/homeassistant/components/ld2410_ble/manifest.json index 0c77e0e2ef5684..798a80147de526 100644 --- a/homeassistant/components/ld2410_ble/manifest.json +++ b/homeassistant/components/ld2410_ble/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/ld2410_ble", "integration_type": "device", "iot_class": "local_push", - "requirements": ["bluetooth-data-tools==1.9.1", "ld2410-ble==0.1.1"] + "requirements": ["bluetooth-data-tools==1.11.0", "ld2410-ble==0.1.1"] } diff --git a/homeassistant/components/led_ble/manifest.json b/homeassistant/components/led_ble/manifest.json index 36e3b7355ff41f..da5b4b0a4ee30d 100644 --- a/homeassistant/components/led_ble/manifest.json +++ b/homeassistant/components/led_ble/manifest.json @@ -32,5 +32,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/led_ble", "iot_class": "local_polling", - "requirements": ["bluetooth-data-tools==1.9.1", "led-ble==1.0.0"] + "requirements": ["bluetooth-data-tools==1.11.0", "led-ble==1.0.0"] } diff --git a/homeassistant/components/life360/__init__.py b/homeassistant/components/life360/__init__.py index 271f934e1c7d52..c6e0fad14c64ef 100644 --- a/homeassistant/components/life360/__init__.py +++ b/homeassistant/components/life360/__init__.py @@ -39,7 +39,7 @@ ) from .coordinator import Life360DataUpdateCoordinator, MissingLocReason -PLATFORMS = [Platform.DEVICE_TRACKER] +PLATFORMS = [Platform.DEVICE_TRACKER, Platform.BUTTON] CONF_ACCOUNTS = "accounts" diff --git a/homeassistant/components/life360/button.py b/homeassistant/components/life360/button.py new file mode 100644 index 00000000000000..6b460c8531c403 --- /dev/null +++ b/homeassistant/components/life360/button.py @@ -0,0 +1,56 @@ +"""Support for Life360 buttons.""" +from homeassistant.components.button import ButtonEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import Life360DataUpdateCoordinator +from .const import DOMAIN + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Life360 buttons.""" + coordinator: Life360DataUpdateCoordinator = hass.data[DOMAIN].coordinators[ + config_entry.entry_id + ] + for member_id, member in coordinator.data.members.items(): + async_add_entities( + [ + Life360UpdateLocationButton(coordinator, member.circle_id, member_id), + ] + ) + + +class Life360UpdateLocationButton( + CoordinatorEntity[Life360DataUpdateCoordinator], ButtonEntity +): + """Represent an Life360 Update Location button.""" + + _attr_has_entity_name = True + _attr_translation_key = "update_location" + + def __init__( + self, + coordinator: Life360DataUpdateCoordinator, + circle_id: str, + member_id: str, + ) -> None: + """Initialize a new Life360 Update Location button.""" + super().__init__(coordinator) + self._circle_id = circle_id + self._member_id = member_id + self._attr_unique_id = f"{member_id}-update-location" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, member_id)}, + name=coordinator.data.members[member_id].name, + ) + + async def async_press(self) -> None: + """Handle the button press.""" + await self.coordinator.update_location(self._circle_id, self._member_id) diff --git a/homeassistant/components/life360/coordinator.py b/homeassistant/components/life360/coordinator.py index 5ea64d3f81d284..755fa1b812434d 100644 --- a/homeassistant/components/life360/coordinator.py +++ b/homeassistant/components/life360/coordinator.py @@ -65,6 +65,7 @@ class Life360Member: at_loc_since: datetime battery_charging: bool battery_level: int + circle_id: str driving: bool entity_picture: str gps_accuracy: int @@ -118,6 +119,10 @@ async def _retrieve_data(self, func: str, *args: Any) -> list[dict[str, Any]]: LOGGER.debug("%s: %s", exc.__class__.__name__, exc) raise UpdateFailed(exc) from exc + async def update_location(self, circle_id: str, member_id: str) -> None: + """Update location for given Circle and Member.""" + await self._retrieve_data("update_location", circle_id, member_id) + async def _async_update_data(self) -> Life360Data: """Get & process data from Life360.""" @@ -214,6 +219,7 @@ async def _async_update_data(self) -> Life360Data: dt_util.utc_from_timestamp(int(loc["since"])), bool(int(loc["charge"])), int(float(loc["battery"])), + circle_id, bool(int(loc["isDriving"])), member["avatar"], # Life360 reports accuracy in feet, but Device Tracker expects diff --git a/homeassistant/components/life360/strings.json b/homeassistant/components/life360/strings.json index cc31ca64a0870a..343d9e95bb84b0 100644 --- a/homeassistant/components/life360/strings.json +++ b/homeassistant/components/life360/strings.json @@ -27,6 +27,13 @@ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, + "entity": { + "button": { + "update_location": { + "name": "Update Location" + } + } + }, "options": { "step": { "init": { diff --git a/homeassistant/components/lifx/manifest.json b/homeassistant/components/lifx/manifest.json index d6b253bd4784a5..7cabfd4712f4d5 100644 --- a/homeassistant/components/lifx/manifest.json +++ b/homeassistant/components/lifx/manifest.json @@ -1,7 +1,7 @@ { "domain": "lifx", "name": "LIFX", - "codeowners": ["@bdraco"], + "codeowners": [], "config_flow": true, "dependencies": ["network"], "dhcp": [ @@ -39,7 +39,6 @@ }, "iot_class": "local_polling", "loggers": ["aiolifx", "aiolifx_effects", "bitstring"], - "quality_scale": "platinum", "requirements": [ "aiolifx==0.8.10", "aiolifx-effects==0.3.2", diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index f7f0150bdd202e..cfcb1e13a07ea6 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -785,6 +785,17 @@ class LightEntityDescription(ToggleEntityDescription): class LightEntity(ToggleEntity): """Base class for light entities.""" + _entity_component_unrecorded_attributes = frozenset( + { + ATTR_SUPPORTED_COLOR_MODES, + ATTR_EFFECT_LIST, + ATTR_MIN_MIREDS, + ATTR_MAX_MIREDS, + ATTR_MIN_COLOR_TEMP_KELVIN, + ATTR_MAX_COLOR_TEMP_KELVIN, + } + ) + entity_description: LightEntityDescription _attr_brightness: int | None = None _attr_color_mode: ColorMode | str | None = None diff --git a/homeassistant/components/light/recorder.py b/homeassistant/components/light/recorder.py deleted file mode 100644 index e38ba888e7199c..00000000000000 --- a/homeassistant/components/light/recorder.py +++ /dev/null @@ -1,26 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.core import HomeAssistant, callback - -from . import ( - ATTR_EFFECT_LIST, - ATTR_MAX_COLOR_TEMP_KELVIN, - ATTR_MAX_MIREDS, - ATTR_MIN_COLOR_TEMP_KELVIN, - ATTR_MIN_MIREDS, - ATTR_SUPPORTED_COLOR_MODES, -) - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude static attributes from being recorded in the database.""" - return { - ATTR_SUPPORTED_COLOR_MODES, - ATTR_EFFECT_LIST, - ATTR_MIN_MIREDS, - ATTR_MAX_MIREDS, - ATTR_MIN_COLOR_TEMP_KELVIN, - ATTR_MAX_COLOR_TEMP_KELVIN, - } diff --git a/homeassistant/components/limitlessled/light.py b/homeassistant/components/limitlessled/light.py index 6677768dd0045f..c1dfeda172c1bf 100644 --- a/homeassistant/components/limitlessled/light.py +++ b/homeassistant/components/limitlessled/light.py @@ -182,20 +182,18 @@ def decorator(function): def wrapper(self: LimitlessLEDGroup, **kwargs: Any) -> None: """Wrap a group state change.""" - # pylint: disable=protected-access - pipeline = Pipeline() transition_time = DEFAULT_TRANSITION if self.effect == EFFECT_COLORLOOP: self.group.stop() - self._attr_effect = None + self._attr_effect = None # pylint: disable=protected-access # Set transition time. if ATTR_TRANSITION in kwargs: transition_time = int(kwargs[ATTR_TRANSITION]) # Do group type-specific work. function(self, transition_time, pipeline, **kwargs) # Update state. - self._attr_is_on = new_state + self._attr_is_on = new_state # pylint: disable=protected-access self.group.enqueue(pipeline) self.schedule_update_ha_state() diff --git a/homeassistant/components/livisi/__init__.py b/homeassistant/components/livisi/__init__.py index b0387c6dcc9203..e638c84a917590 100644 --- a/homeassistant/components/livisi/__init__.py +++ b/homeassistant/components/livisi/__init__.py @@ -33,7 +33,7 @@ async def async_setup_entry(hass: core.HomeAssistant, entry: ConfigEntry) -> boo hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator device_registry = dr.async_get(hass) device_registry.async_get_or_create( - config_entry_id=coordinator.serial_number, + config_entry_id=entry.entry_id, identifiers={(DOMAIN, entry.entry_id)}, manufacturer="Livisi", name=f"SHC {coordinator.controller_type} {coordinator.serial_number}", diff --git a/homeassistant/components/livisi/entity.py b/homeassistant/components/livisi/entity.py index 5ddba1e2e86355..b7b9bdc8521078 100644 --- a/homeassistant/components/livisi/entity.py +++ b/homeassistant/components/livisi/entity.py @@ -64,8 +64,10 @@ def __init__( ) super().__init__(coordinator) + # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: """Register callback for reachability.""" + await super().async_added_to_hass() self.async_on_remove( async_dispatcher_connect( self.hass, diff --git a/homeassistant/components/logi_circle/camera.py b/homeassistant/components/logi_circle/camera.py index 77c0f2f24c8def..d1ea01e864cc74 100644 --- a/homeassistant/components/logi_circle/camera.py +++ b/homeassistant/components/logi_circle/camera.py @@ -71,10 +71,17 @@ def __init__(self, camera, ffmpeg): """Initialize Logi Circle camera.""" super().__init__() self._camera = camera - self._id = self._camera.mac_address - self._has_battery = self._camera.supports_feature("battery_level") + self._has_battery = camera.supports_feature("battery_level") self._ffmpeg = ffmpeg self._listeners = [] + self._attr_unique_id = camera.mac_address + self._attr_device_info = DeviceInfo( + identifiers={(LOGI_CIRCLE_DOMAIN, camera.id)}, + manufacturer=DEVICE_BRAND, + model=camera.model_name, + name=camera.name, + sw_version=camera.firmware, + ) async def async_added_to_hass(self) -> None: """Connect camera methods to signals.""" @@ -117,27 +124,6 @@ async def async_will_remove_from_hass(self) -> None: for detach in self._listeners: detach() - @property - def unique_id(self): - """Return a unique ID.""" - return self._id - - @property - def name(self): - """Return the name of this camera.""" - return self._name - - @property - def device_info(self) -> DeviceInfo: - """Return information about the device.""" - return DeviceInfo( - identifiers={(LOGI_CIRCLE_DOMAIN, self._camera.id)}, - manufacturer=DEVICE_BRAND, - model=self._camera.model_name, - name=self._camera.name, - sw_version=self._camera.firmware, - ) - @property def extra_state_attributes(self): """Return the state attributes.""" diff --git a/homeassistant/components/logi_circle/sensor.py b/homeassistant/components/logi_circle/sensor.py index 32082b794b73d1..d06569a19ca90a 100644 --- a/homeassistant/components/logi_circle/sensor.py +++ b/homeassistant/components/logi_circle/sensor.py @@ -106,16 +106,12 @@ def __init__(self, camera, time_zone, description: SensorEntityDescription) -> N self._attr_unique_id = f"{camera.mac_address}-{description.key}" self._activity: dict[Any, Any] = {} self._tz = time_zone - - @property - def device_info(self) -> DeviceInfo: - """Return information about the device.""" - return DeviceInfo( - identifiers={(LOGI_CIRCLE_DOMAIN, self._camera.id)}, + self._attr_device_info = DeviceInfo( + identifiers={(LOGI_CIRCLE_DOMAIN, camera.id)}, manufacturer=DEVICE_BRAND, - model=self._camera.model_name, - name=self._camera.name, - sw_version=self._camera.firmware, + model=camera.model_name, + name=camera.name, + sw_version=camera.firmware, ) @property diff --git a/homeassistant/components/london_underground/const.py b/homeassistant/components/london_underground/const.py new file mode 100644 index 00000000000000..4928d3bb1641d5 --- /dev/null +++ b/homeassistant/components/london_underground/const.py @@ -0,0 +1,26 @@ +"""Constants for the London underground integration.""" +from datetime import timedelta + +DOMAIN = "london_underground" + +CONF_LINE = "line" + + +SCAN_INTERVAL = timedelta(seconds=30) + +TUBE_LINES = [ + "Bakerloo", + "Central", + "Circle", + "District", + "DLR", + "Elizabeth line", + "Hammersmith & City", + "Jubilee", + "London Overground", + "Metropolitan", + "Northern", + "Piccadilly", + "Victoria", + "Waterloo & City", +] diff --git a/homeassistant/components/london_underground/coordinator.py b/homeassistant/components/london_underground/coordinator.py new file mode 100644 index 00000000000000..a094d099896835 --- /dev/null +++ b/homeassistant/components/london_underground/coordinator.py @@ -0,0 +1,30 @@ +"""DataUpdateCoordinator for London underground integration.""" +from __future__ import annotations + +import asyncio +import logging + +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN, SCAN_INTERVAL + +_LOGGER = logging.getLogger(__name__) + + +class LondonTubeCoordinator(DataUpdateCoordinator): + """London Underground sensor coordinator.""" + + def __init__(self, hass, data): + """Initialize coordinator.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + self._data = data + + async def _async_update_data(self): + async with asyncio.timeout(10): + await self._data.update() + return self._data.data diff --git a/homeassistant/components/london_underground/manifest.json b/homeassistant/components/london_underground/manifest.json index acdb83a2359df5..eafc63c6ae7386 100644 --- a/homeassistant/components/london_underground/manifest.json +++ b/homeassistant/components/london_underground/manifest.json @@ -1,7 +1,7 @@ { "domain": "london_underground", "name": "London Underground", - "codeowners": [], + "codeowners": ["@jpbede"], "documentation": "https://www.home-assistant.io/integrations/london_underground", "iot_class": "cloud_polling", "loggers": ["london_tube_status"], diff --git a/homeassistant/components/london_underground/sensor.py b/homeassistant/components/london_underground/sensor.py index 7e52186fa51763..c0d0eeca372f83 100644 --- a/homeassistant/components/london_underground/sensor.py +++ b/homeassistant/components/london_underground/sensor.py @@ -1,8 +1,6 @@ """Sensor for checking the status of London Underground tube lines.""" from __future__ import annotations -import asyncio -from datetime import timedelta import logging from london_tube_status import TubeData @@ -15,36 +13,12 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) - -_LOGGER = logging.getLogger(__name__) - -DOMAIN = "london_underground" - -CONF_LINE = "line" - +from homeassistant.helpers.update_coordinator import CoordinatorEntity -SCAN_INTERVAL = timedelta(seconds=30) +from .const import CONF_LINE, TUBE_LINES +from .coordinator import LondonTubeCoordinator -TUBE_LINES = [ - "Bakerloo", - "Central", - "Circle", - "District", - "DLR", - "Elizabeth line", - "Hammersmith & City", - "Jubilee", - "London Overground", - "Metropolitan", - "Northern", - "Piccadilly", - "Victoria", - "Waterloo & City", -] +_LOGGER = logging.getLogger(__name__) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( {vol.Required(CONF_LINE): vol.All(cv.ensure_list, [vol.In(list(TUBE_LINES))])} @@ -76,25 +50,6 @@ async def async_setup_platform( async_add_entities(sensors) -class LondonTubeCoordinator(DataUpdateCoordinator): - """London Underground sensor coordinator.""" - - def __init__(self, hass, data): - """Initialize coordinator.""" - super().__init__( - hass, - _LOGGER, - name=DOMAIN, - update_interval=SCAN_INTERVAL, - ) - self._data = data - - async def _async_update_data(self): - async with asyncio.timeout(10): - await self._data.update() - return self._data.data - - class LondonTubeSensor(CoordinatorEntity[LondonTubeCoordinator], SensorEntity): """Sensor that reads the status of a line from Tube Data.""" diff --git a/homeassistant/components/lookin/entity.py b/homeassistant/components/lookin/entity.py index d20a21bd23c9fe..0e518ffc1e5eee 100644 --- a/homeassistant/components/lookin/entity.py +++ b/homeassistant/components/lookin/entity.py @@ -182,6 +182,7 @@ async def _async_push_update_device(self, event: UDPEvent) -> None: async def async_added_to_hass(self) -> None: """Call when the entity is added to hass.""" + await super().async_added_to_hass() self.async_on_remove( self._lookin_udp_subs.subscribe_event( self._lookin_device.id, diff --git a/homeassistant/components/lutron_caseta/binary_sensor.py b/homeassistant/components/lutron_caseta/binary_sensor.py index 334590c0e65c61..da7d6106796cc0 100644 --- a/homeassistant/components/lutron_caseta/binary_sensor.py +++ b/homeassistant/components/lutron_caseta/binary_sensor.py @@ -63,6 +63,7 @@ def is_on(self): """Return the brightness of the light.""" return self._device["status"] == OCCUPANCY_GROUP_OCCUPIED + # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: """Register callbacks.""" self._smartbridge.add_occupancy_subscriber( diff --git a/homeassistant/components/lutron_caseta/manifest.json b/homeassistant/components/lutron_caseta/manifest.json index feab9744df0151..bf6ed32c6680ff 100644 --- a/homeassistant/components/lutron_caseta/manifest.json +++ b/homeassistant/components/lutron_caseta/manifest.json @@ -9,7 +9,7 @@ }, "iot_class": "local_push", "loggers": ["pylutron_caseta"], - "requirements": ["pylutron-caseta==0.18.1"], + "requirements": ["pylutron-caseta==0.18.2"], "zeroconf": [ { "type": "_lutron._tcp.local.", diff --git a/homeassistant/components/lyric/__init__.py b/homeassistant/components/lyric/__init__.py index 3e83fedb72a5fb..d048b31d0b0619 100644 --- a/homeassistant/components/lyric/__init__.py +++ b/homeassistant/components/lyric/__init__.py @@ -133,6 +133,7 @@ def __init__( self._location = location self._mac_id = device.macID self._update_thermostat = coordinator.data.update_thermostat + self._update_fan = coordinator.data.update_fan @property def unique_id(self) -> str: diff --git a/homeassistant/components/lyric/climate.py b/homeassistant/components/lyric/climate.py index ef662d061e80c0..d0bad55ff14cf6 100644 --- a/homeassistant/components/lyric/climate.py +++ b/homeassistant/components/lyric/climate.py @@ -14,6 +14,9 @@ from homeassistant.components.climate import ( ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, + FAN_AUTO, + FAN_DIFFUSE, + FAN_ON, ClimateEntity, ClimateEntityDescription, ClimateEntityFeature, @@ -67,6 +70,10 @@ LYRIC_HVAC_MODE_COOL = "Cool" LYRIC_HVAC_MODE_HEAT_COOL = "Auto" +LYRIC_FAN_MODE_ON = "On" +LYRIC_FAN_MODE_AUTO = "Auto" +LYRIC_FAN_MODE_DIFFUSE = "Circulate" + LYRIC_HVAC_MODES = { HVACMode.OFF: LYRIC_HVAC_MODE_OFF, HVACMode.HEAT: LYRIC_HVAC_MODE_HEAT, @@ -81,6 +88,18 @@ LYRIC_HVAC_MODE_HEAT_COOL: HVACMode.HEAT_COOL, } +LYRIC_FAN_MODES = { + FAN_ON: LYRIC_FAN_MODE_ON, + FAN_AUTO: LYRIC_FAN_MODE_AUTO, + FAN_DIFFUSE: LYRIC_FAN_MODE_DIFFUSE, +} + +FAN_MODES = { + LYRIC_FAN_MODE_ON: FAN_ON, + LYRIC_FAN_MODE_AUTO: FAN_AUTO, + LYRIC_FAN_MODE_DIFFUSE: FAN_DIFFUSE, +} + HVAC_ACTIONS = { LYRIC_HVAC_ACTION_OFF: HVACAction.OFF, LYRIC_HVAC_ACTION_HEAT: HVACAction.HEATING, @@ -139,6 +158,13 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity): entity_description: ClimateEntityDescription _attr_name = None + _attr_preset_modes = [ + PRESET_NO_HOLD, + PRESET_HOLD_UNTIL, + PRESET_PERMANENT_HOLD, + PRESET_TEMPORARY_HOLD, + PRESET_VACATION_HOLD, + ] def __init__( self, @@ -172,6 +198,25 @@ def __init__( ): self._attr_hvac_modes.append(HVACMode.HEAT_COOL) + # Setup supported features + if device.changeableValues.thermostatSetpointStatus: + self._attr_supported_features = SUPPORT_FLAGS_LCC + else: + self._attr_supported_features = SUPPORT_FLAGS_TCC + + # Setup supported fan modes + if device_fan_modes := device.settings.attributes.get("fan", {}).get( + "allowedModes" + ): + self._attr_fan_modes = [ + FAN_MODES[device_fan_mode] + for device_fan_mode in device_fan_modes + if device_fan_mode in FAN_MODES + ] + self._attr_supported_features = ( + self._attr_supported_features | ClimateEntityFeature.FAN_MODE + ) + super().__init__( coordinator, location, @@ -180,13 +225,6 @@ def __init__( ) self.entity_description = description - @property - def supported_features(self) -> ClimateEntityFeature: - """Return the list of supported features.""" - if self.device.changeableValues.thermostatSetpointStatus: - return SUPPORT_FLAGS_LCC - return SUPPORT_FLAGS_TCC - @property def current_temperature(self) -> float | None: """Return the current temperature.""" @@ -245,17 +283,6 @@ def preset_mode(self) -> str | None: """Return current preset mode.""" return self.device.changeableValues.thermostatSetpointStatus - @property - def preset_modes(self) -> list[str] | None: - """Return preset modes.""" - return [ - PRESET_NO_HOLD, - PRESET_HOLD_UNTIL, - PRESET_PERMANENT_HOLD, - PRESET_TEMPORARY_HOLD, - PRESET_VACATION_HOLD, - ] - @property def min_temp(self) -> float: """Identify min_temp in Lyric API or defaults if not available.""" @@ -272,6 +299,16 @@ def max_temp(self) -> float: return device.maxHeatSetpoint return device.maxCoolSetpoint + @property + def fan_mode(self) -> str | None: + """Return current fan mode.""" + device = self.device + return FAN_MODES.get( + device.settings.attributes.get("fan", {}) + .get("changeableValues", {}) + .get("mode") + ) + async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" if self.hvac_mode == HVACMode.OFF: @@ -394,3 +431,20 @@ async def async_set_hold_time(self, time_period: str) -> None: except LYRIC_EXCEPTIONS as exception: _LOGGER.error(exception) await self.coordinator.async_refresh() + + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Set fan mode.""" + _LOGGER.debug("Set fan mode: %s", fan_mode) + try: + _LOGGER.debug("Fan mode passed to lyric: %s", LYRIC_FAN_MODES[fan_mode]) + await self._update_fan( + self.location, self.device, mode=LYRIC_FAN_MODES[fan_mode] + ) + except LYRIC_EXCEPTIONS as exception: + _LOGGER.error(exception) + except KeyError: + _LOGGER.error( + "The fan mode requested does not have a corresponding mode in lyric: %s", + fan_mode, + ) + await self.coordinator.async_refresh() diff --git a/homeassistant/components/lyric/sensor.py b/homeassistant/components/lyric/sensor.py index d628a1081830ac..f0a4cdfbb99e58 100644 --- a/homeassistant/components/lyric/sensor.py +++ b/homeassistant/components/lyric/sensor.py @@ -4,7 +4,6 @@ from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timedelta -from typing import cast from aiolyric import Lyric from aiolyric.objects.device import LyricDevice @@ -43,10 +42,86 @@ @dataclass -class LyricSensorEntityDescription(SensorEntityDescription): +class LyricSensorEntityDescriptionMixin: + """Mixin for required keys.""" + + value_fn: Callable[[LyricDevice], StateType | datetime] + suitable_fn: Callable[[LyricDevice], bool] + + +@dataclass +class LyricSensorEntityDescription( + SensorEntityDescription, LyricSensorEntityDescriptionMixin +): """Class describing Honeywell Lyric sensor entities.""" - value: Callable[[LyricDevice], StateType | datetime] = round + +DEVICE_SENSORS: list[LyricSensorEntityDescription] = [ + LyricSensorEntityDescription( + key="indoor_temperature", + translation_key="indoor_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda device: device.indoorTemperature, + suitable_fn=lambda device: device.indoorTemperature, + ), + LyricSensorEntityDescription( + key="indoor_humidity", + translation_key="indoor_humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda device: device.indoorHumidity, + suitable_fn=lambda device: device.indoorHumidity, + ), + LyricSensorEntityDescription( + key="outdoor_temperature", + translation_key="outdoor_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda device: device.outdoorTemperature, + suitable_fn=lambda device: device.outdoorTemperature, + ), + LyricSensorEntityDescription( + key="outdoor_humidity", + translation_key="outdoor_humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda device: device.displayedOutdoorHumidity, + suitable_fn=lambda device: device.displayedOutdoorHumidity, + ), + LyricSensorEntityDescription( + key="next_period_time", + translation_key="next_period_time", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda device: get_datetime_from_future_time( + device.changeableValues.nextPeriodTime + ), + suitable_fn=lambda device: ( + device.changeableValues and device.changeableValues.nextPeriodTime + ), + ), + LyricSensorEntityDescription( + key="setpoint_status", + translation_key="setpoint_status", + icon="mdi:thermostat", + value_fn=lambda device: get_setpoint_status( + device.changeableValues.thermostatSetpointStatus, + device.changeableValues.nextPeriodTime, + ), + suitable_fn=lambda device: ( + device.changeableValues and device.changeableValues.thermostatSetpointStatus + ), + ), +] + + +def get_setpoint_status(status: str, time: str) -> str | None: + """Get status of the setpoint.""" + if status == PRESET_HOLD_UNTIL: + return f"Held until {time}" + return LYRIC_SETPOINT_STATUS_NAMES.get(status) def get_datetime_from_future_time(time_str: str) -> datetime: @@ -68,129 +143,25 @@ async def async_setup_entry( entities = [] - def get_setpoint_status(status: str, time: str) -> str | None: - if status == PRESET_HOLD_UNTIL: - return f"Held until {time}" - return LYRIC_SETPOINT_STATUS_NAMES.get(status, None) - for location in coordinator.data.locations: for device in location.devices: - if device.indoorTemperature: - if device.units == "Fahrenheit": - native_temperature_unit = UnitOfTemperature.FAHRENHEIT - else: - native_temperature_unit = UnitOfTemperature.CELSIUS - - entities.append( - LyricSensor( - coordinator, - LyricSensorEntityDescription( - key=f"{device.macID}_indoor_temperature", - translation_key="indoor_temperature", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=native_temperature_unit, - value=lambda device: device.indoorTemperature, - ), - location, - device, - ) - ) - if device.indoorHumidity: - entities.append( - LyricSensor( - coordinator, - LyricSensorEntityDescription( - key=f"{device.macID}_indoor_humidity", - translation_key="indoor_humidity", - device_class=SensorDeviceClass.HUMIDITY, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=PERCENTAGE, - value=lambda device: device.indoorHumidity, - ), - location, - device, - ) - ) - if device.outdoorTemperature: - if device.units == "Fahrenheit": - native_temperature_unit = UnitOfTemperature.FAHRENHEIT - else: - native_temperature_unit = UnitOfTemperature.CELSIUS - - entities.append( - LyricSensor( - coordinator, - LyricSensorEntityDescription( - key=f"{device.macID}_outdoor_temperature", - translation_key="outdoor_temperature", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=native_temperature_unit, - value=lambda device: device.outdoorTemperature, - ), - location, - device, - ) - ) - if device.displayedOutdoorHumidity: - entities.append( - LyricSensor( - coordinator, - LyricSensorEntityDescription( - key=f"{device.macID}_outdoor_humidity", - translation_key="outdoor_humidity", - device_class=SensorDeviceClass.HUMIDITY, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=PERCENTAGE, - value=lambda device: device.displayedOutdoorHumidity, - ), - location, - device, - ) - ) - if device.changeableValues: - if device.changeableValues.nextPeriodTime: - entities.append( - LyricSensor( - coordinator, - LyricSensorEntityDescription( - key=f"{device.macID}_next_period_time", - translation_key="next_period_time", - device_class=SensorDeviceClass.TIMESTAMP, - value=lambda device: get_datetime_from_future_time( - device.changeableValues.nextPeriodTime - ), - ), - location, - device, - ) - ) - if device.changeableValues.thermostatSetpointStatus: + for device_sensor in DEVICE_SENSORS: + if device_sensor.suitable_fn(device): entities.append( LyricSensor( coordinator, - LyricSensorEntityDescription( - key=f"{device.macID}_setpoint_status", - translation_key="setpoint_status", - icon="mdi:thermostat", - value=lambda device: get_setpoint_status( - device.changeableValues.thermostatSetpointStatus, - device.changeableValues.nextPeriodTime, - ), - ), + device_sensor, location, device, ) ) - async_add_entities(entities, True) + async_add_entities(entities) class LyricSensor(LyricDeviceEntity, SensorEntity): """Define a Honeywell Lyric sensor.""" - coordinator: DataUpdateCoordinator[Lyric] entity_description: LyricSensorEntityDescription def __init__( @@ -205,15 +176,16 @@ def __init__( coordinator, location, device, - description.key, + f"{device.macID}_{description.key}", ) self.entity_description = description + if description.device_class == SensorDeviceClass.TEMPERATURE: + if device.units == "Fahrenheit": + self._attr_native_unit_of_measurement = UnitOfTemperature.FAHRENHEIT + else: + self._attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS @property - def native_value(self) -> StateType: + def native_value(self) -> StateType | datetime: """Return the state.""" - device: LyricDevice = self.device - try: - return cast(StateType, self.entity_description.value(device)) - except TypeError: - return None + return self.entity_description.value_fn(self.device) diff --git a/homeassistant/components/matrix/__init__.py b/homeassistant/components/matrix/__init__.py index febafc367f1be2..cf7bcce7b3c7e6 100644 --- a/homeassistant/components/matrix/__init__.py +++ b/homeassistant/components/matrix/__init__.py @@ -1,10 +1,28 @@ """The Matrix bot component.""" -from functools import partial +from __future__ import annotations + +import asyncio import logging import mimetypes import os - -from matrix_client.client import MatrixClient, MatrixRequestError +import re +from typing import NewType, TypedDict + +import aiofiles.os +from nio import AsyncClient, Event, MatrixRoom +from nio.events.room_events import RoomMessageText +from nio.responses import ( + ErrorResponse, + JoinError, + JoinResponse, + LoginError, + Response, + UploadError, + UploadResponse, + WhoamiError, + WhoamiResponse, +) +from PIL import Image import voluptuous as vol from homeassistant.components.notify import ATTR_DATA, ATTR_MESSAGE, ATTR_TARGET @@ -16,8 +34,8 @@ EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, ) -from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.exceptions import HomeAssistantError +from homeassistant.core import Event as HassEvent, HomeAssistant, ServiceCall +from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.json import save_json from homeassistant.helpers.typing import ConfigType @@ -35,23 +53,37 @@ CONF_WORD = "word" CONF_EXPRESSION = "expression" +EVENT_MATRIX_COMMAND = "matrix_command" + DEFAULT_CONTENT_TYPE = "application/octet-stream" MESSAGE_FORMATS = [FORMAT_HTML, FORMAT_TEXT] DEFAULT_MESSAGE_FORMAT = FORMAT_TEXT -EVENT_MATRIX_COMMAND = "matrix_command" - ATTR_FORMAT = "format" # optional message format ATTR_IMAGES = "images" # optional images +WordCommand = NewType("WordCommand", str) +ExpressionCommand = NewType("ExpressionCommand", re.Pattern) +RoomID = NewType("RoomID", str) + + +class ConfigCommand(TypedDict, total=False): + """Corresponds to a single COMMAND_SCHEMA.""" + + name: str # CONF_NAME + rooms: list[RoomID] | None # CONF_ROOMS + word: WordCommand | None # CONF_WORD + expression: ExpressionCommand | None # CONF_EXPRESSION + + COMMAND_SCHEMA = vol.All( vol.Schema( { vol.Exclusive(CONF_WORD, "trigger"): cv.string, vol.Exclusive(CONF_EXPRESSION, "trigger"): cv.is_regex, vol.Required(CONF_NAME): cv.string, - vol.Optional(CONF_ROOMS, default=[]): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_ROOMS): vol.All(cv.ensure_list, [cv.string]), } ), cv.has_at_least_one_key(CONF_WORD, CONF_EXPRESSION), @@ -75,7 +107,6 @@ extra=vol.ALLOW_EXTRA, ) - SERVICE_SCHEMA_SEND_MESSAGE = vol.Schema( { vol.Required(ATTR_MESSAGE): cv.string, @@ -90,30 +121,26 @@ ) -def setup(hass: HomeAssistant, config: ConfigType) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Matrix bot component.""" config = config[DOMAIN] - try: - bot = MatrixBot( - hass, - os.path.join(hass.config.path(), SESSION_FILE), - config[CONF_HOMESERVER], - config[CONF_VERIFY_SSL], - config[CONF_USERNAME], - config[CONF_PASSWORD], - config[CONF_ROOMS], - config[CONF_COMMANDS], - ) - hass.data[DOMAIN] = bot - except MatrixRequestError as exception: - _LOGGER.error("Matrix failed to log in: %s", str(exception)) - return False + matrix_bot = MatrixBot( + hass, + os.path.join(hass.config.path(), SESSION_FILE), + config[CONF_HOMESERVER], + config[CONF_VERIFY_SSL], + config[CONF_USERNAME], + config[CONF_PASSWORD], + config[CONF_ROOMS], + config[CONF_COMMANDS], + ) + hass.data[DOMAIN] = matrix_bot - hass.services.register( + hass.services.async_register( DOMAIN, SERVICE_SEND_MESSAGE, - bot.handle_send_message, + matrix_bot.handle_send_message, schema=SERVICE_SCHEMA_SEND_MESSAGE, ) @@ -123,164 +150,141 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: class MatrixBot: """The Matrix Bot.""" + _client: AsyncClient + def __init__( self, - hass, - config_file, - homeserver, - verify_ssl, - username, - password, - listening_rooms, - commands, - ): + hass: HomeAssistant, + config_file: str, + homeserver: str, + verify_ssl: bool, + username: str, + password: str, + listening_rooms: list[RoomID], + commands: list[ConfigCommand], + ) -> None: """Set up the client.""" self.hass = hass self._session_filepath = config_file - self._auth_tokens = self._get_auth_tokens() + self._access_tokens: JsonObjectType = {} self._homeserver = homeserver self._verify_tls = verify_ssl self._mx_id = username self._password = password - self._listening_rooms = listening_rooms - - # We have to fetch the aliases for every room to make sure we don't - # join it twice by accident. However, fetching aliases is costly, - # so we only do it once per room. - self._aliases_fetched_for = set() - - # Word commands are stored dict-of-dict: First dict indexes by room ID - # / alias, second dict indexes by the word - self._word_commands = {} + self._client = AsyncClient( + homeserver=self._homeserver, user=self._mx_id, ssl=self._verify_tls + ) - # Regular expression commands are stored as a list of commands per - # room, i.e., a dict-of-list - self._expression_commands = {} + self._listening_rooms = listening_rooms - for command in commands: - if not command.get(CONF_ROOMS): - command[CONF_ROOMS] = listening_rooms - - if command.get(CONF_WORD): - for room_id in command[CONF_ROOMS]: - if room_id not in self._word_commands: - self._word_commands[room_id] = {} - self._word_commands[room_id][command[CONF_WORD]] = command - else: - for room_id in command[CONF_ROOMS]: - if room_id not in self._expression_commands: - self._expression_commands[room_id] = [] - self._expression_commands[room_id].append(command) + self._word_commands: dict[RoomID, dict[WordCommand, ConfigCommand]] = {} + self._expression_commands: dict[RoomID, list[ConfigCommand]] = {} + self._load_commands(commands) - # Log in. This raises a MatrixRequestError if login is unsuccessful - self._client = self._login() + async def stop_client(event: HassEvent) -> None: + """Run once when Home Assistant stops.""" + if self._client is not None: + await self._client.close() - def handle_matrix_exception(exception): - """Handle exceptions raised inside the Matrix SDK.""" - _LOGGER.error("Matrix exception:\n %s", str(exception)) + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_client) - self._client.start_listener_thread(exception_handler=handle_matrix_exception) + async def handle_startup(event: HassEvent) -> None: + """Run once when Home Assistant finished startup.""" + self._access_tokens = await self._get_auth_tokens() + await self._login() + await self._join_rooms() + # Sync once so that we don't respond to past events. + await self._client.sync(timeout=30_000) - def stop_client(_): - """Run once when Home Assistant stops.""" - self._client.stop_listener_thread() + self._client.add_event_callback(self._handle_room_message, RoomMessageText) - self.hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_client) + await self._client.sync_forever( + timeout=30_000, + loop_sleep_time=1_000, + ) # milliseconds. - # Joining rooms potentially does a lot of I/O, so we defer it - def handle_startup(_): - """Run once when Home Assistant finished startup.""" - self._join_rooms() + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, handle_startup) - self.hass.bus.listen_once(EVENT_HOMEASSISTANT_START, handle_startup) + def _load_commands(self, commands: list[ConfigCommand]) -> None: + for command in commands: + # Set the command for all listening_rooms, unless otherwise specified. + command.setdefault(CONF_ROOMS, self._listening_rooms) # type: ignore[misc] + + # COMMAND_SCHEMA guarantees that exactly one of CONF_WORD and CONF_expression are set. + if (word_command := command.get(CONF_WORD)) is not None: + for room_id in command[CONF_ROOMS]: # type: ignore[literal-required] + self._word_commands.setdefault(room_id, {}) + self._word_commands[room_id][word_command] = command # type: ignore[index] + else: + for room_id in command[CONF_ROOMS]: # type: ignore[literal-required] + self._expression_commands.setdefault(room_id, []) + self._expression_commands[room_id].append(command) - def _handle_room_message(self, room_id, room, event): + async def _handle_room_message(self, room: MatrixRoom, message: Event) -> None: """Handle a message sent to a Matrix room.""" - if event["content"]["msgtype"] != "m.text": + # Corresponds to message type 'm.text' and NOT other RoomMessage subtypes, like 'm.notice' and 'm.emote'. + if not isinstance(message, RoomMessageText): return - - if event["sender"] == self._mx_id: + # Don't respond to our own messages. + if message.sender == self._mx_id: return + _LOGGER.debug("Handling message: %s", message.body) - _LOGGER.debug("Handling message: %s", event["content"]["body"]) + room_id = RoomID(room.room_id) - if event["content"]["body"][0] == "!": - # Could trigger a single-word command - pieces = event["content"]["body"].split(" ") - cmd = pieces[0][1:] + if message.body.startswith("!"): + # Could trigger a single-word command. + pieces = message.body.split() + word = WordCommand(pieces[0].lstrip("!")) - command = self._word_commands.get(room_id, {}).get(cmd) - if command: - event_data = { + if command := self._word_commands.get(room_id, {}).get(word): + message_data = { "command": command[CONF_NAME], - "sender": event["sender"], + "sender": message.sender, "room": room_id, "args": pieces[1:], } - self.hass.bus.fire(EVENT_MATRIX_COMMAND, event_data) + self.hass.bus.async_fire(EVENT_MATRIX_COMMAND, message_data) - # After single-word commands, check all regex commands in the room + # After single-word commands, check all regex commands in the room. for command in self._expression_commands.get(room_id, []): - match = command[CONF_EXPRESSION].match(event["content"]["body"]) + match: re.Match = command[CONF_EXPRESSION].match(message.body) # type: ignore[literal-required] if not match: continue - event_data = { + message_data = { "command": command[CONF_NAME], - "sender": event["sender"], + "sender": message.sender, "room": room_id, "args": match.groupdict(), } - self.hass.bus.fire(EVENT_MATRIX_COMMAND, event_data) - - def _join_or_get_room(self, room_id_or_alias): - """Join a room or get it, if we are already in the room. - - We can't just always call join_room(), since that seems to crash - the client if we're already in the room. - """ - rooms = self._client.get_rooms() - if room_id_or_alias in rooms: - _LOGGER.debug("Already in room %s", room_id_or_alias) - return rooms[room_id_or_alias] - - for room in rooms.values(): - if room.room_id not in self._aliases_fetched_for: - room.update_aliases() - self._aliases_fetched_for.add(room.room_id) - - if ( - room_id_or_alias in room.aliases - or room_id_or_alias == room.canonical_alias - ): - _LOGGER.debug( - "Already in room %s (known as %s)", room.room_id, room_id_or_alias - ) - return room - - room = self._client.join_room(room_id_or_alias) - _LOGGER.info("Joined room %s (known as %s)", room.room_id, room_id_or_alias) - return room + self.hass.bus.async_fire(EVENT_MATRIX_COMMAND, message_data) + + async def _join_room(self, room_id_or_alias: str) -> None: + """Join a room or do nothing if already joined.""" + join_response = await self._client.join(room_id_or_alias) + + if isinstance(join_response, JoinResponse): + _LOGGER.debug("Joined or already in room '%s'", room_id_or_alias) + elif isinstance(join_response, JoinError): + _LOGGER.error( + "Could not join room '%s': %s", + room_id_or_alias, + join_response, + ) - def _join_rooms(self): + async def _join_rooms(self) -> None: """Join the Matrix rooms that we listen for commands in.""" - for room_id in self._listening_rooms: - try: - room = self._join_or_get_room(room_id) - room.add_listener( - partial(self._handle_room_message, room_id), "m.room.message" - ) - - except MatrixRequestError as ex: - _LOGGER.error("Could not join room %s: %s", room_id, ex) - - def _get_auth_tokens(self) -> JsonObjectType: - """Read sorted authentication tokens from disk. - - Returns the auth_tokens dictionary. - """ + rooms = [ + self.hass.async_create_task(self._join_room(room_id)) + for room_id in self._listening_rooms + ] + await asyncio.wait(rooms) + + async def _get_auth_tokens(self) -> JsonObjectType: + """Read sorted authentication tokens from disk.""" try: return load_json_object(self._session_filepath) except HomeAssistantError as ex: @@ -291,116 +295,179 @@ def _get_auth_tokens(self) -> JsonObjectType: ) return {} - def _store_auth_token(self, token): + async def _store_auth_token(self, token: str) -> None: """Store authentication token to session and persistent storage.""" - self._auth_tokens[self._mx_id] = token + self._access_tokens[self._mx_id] = token - save_json(self._session_filepath, self._auth_tokens) + await self.hass.async_add_executor_job( + save_json, self._session_filepath, self._access_tokens, True # private=True + ) - def _login(self): - """Login to the Matrix homeserver and return the client instance.""" - # Attempt to generate a valid client using either of the two possible - # login methods: - client = None + async def _login(self) -> None: + """Log in to the Matrix homeserver. - # If we have an authentication token - if self._mx_id in self._auth_tokens: - try: - client = self._login_by_token() - _LOGGER.debug("Logged in using stored token") + Attempts to use the stored access token. + If that fails, then tries using the password. + If that also fails, raises LocalProtocolError. + """ - except MatrixRequestError as ex: + # If we have an access token + if (token := self._access_tokens.get(self._mx_id)) is not None: + _LOGGER.debug("Restoring login from stored access token") + self._client.restore_login( + user_id=self._client.user_id, + device_id=self._client.device_id, + access_token=token, + ) + response = await self._client.whoami() + if isinstance(response, WhoamiError): _LOGGER.warning( - "Login by token failed, falling back to password: %d, %s", - ex.code, - ex.content, + "Restoring login from access token failed: %s, %s", + response.status_code, + response.message, + ) + self._client.access_token = ( + "" # Force a soft-logout if the homeserver didn't. + ) + elif isinstance(response, WhoamiResponse): + _LOGGER.debug( + "Successfully restored login from access token: user_id '%s', device_id '%s'", + response.user_id, + response.device_id, ) - # If we still don't have a client try password - if not client: - try: - client = self._login_by_password() - _LOGGER.debug("Logged in using password") - - except MatrixRequestError as ex: - _LOGGER.error( - "Login failed, both token and username/password invalid: %d, %s", - ex.code, - ex.content, + # If the token login did not succeed + if not self._client.logged_in: + response = await self._client.login(password=self._password) + _LOGGER.debug("Logging in using password") + + if isinstance(response, LoginError): + _LOGGER.warning( + "Login by password failed: %s, %s", + response.status_code, + response.message, ) - # Re-raise the error so _setup can catch it - raise - - return client - - def _login_by_token(self): - """Login using authentication token and return the client.""" - return MatrixClient( - base_url=self._homeserver, - token=self._auth_tokens[self._mx_id], - user_id=self._mx_id, - valid_cert_check=self._verify_tls, - ) - def _login_by_password(self): - """Login using password authentication and return the client.""" - _client = MatrixClient( - base_url=self._homeserver, valid_cert_check=self._verify_tls + if not self._client.logged_in: + raise ConfigEntryAuthFailed( + "Login failed, both token and username/password are invalid" + ) + + await self._store_auth_token(self._client.access_token) + + async def _handle_room_send( + self, target_room: RoomID, message_type: str, content: dict + ) -> None: + """Wrap _client.room_send and handle ErrorResponses.""" + response: Response = await self._client.room_send( + room_id=target_room, + message_type=message_type, + content=content, ) + if isinstance(response, ErrorResponse): + _LOGGER.error( + "Unable to deliver message to room '%s': %s", + target_room, + response, + ) + else: + _LOGGER.debug("Message delivered to room '%s'", target_room) + + async def _handle_multi_room_send( + self, target_rooms: list[RoomID], message_type: str, content: dict + ) -> None: + """Wrap _handle_room_send for multiple target_rooms.""" + _tasks = [] + for target_room in target_rooms: + _tasks.append( + self.hass.async_create_task( + self._handle_room_send( + target_room=target_room, + message_type=message_type, + content=content, + ) + ) + ) + await asyncio.wait(_tasks) - _client.login_with_password(self._mx_id, self._password) + async def _send_image(self, image_path: str, target_rooms: list[RoomID]) -> None: + """Upload an image, then send it to all target_rooms.""" + _is_allowed_path = await self.hass.async_add_executor_job( + self.hass.config.is_allowed_path, image_path + ) + if not _is_allowed_path: + _LOGGER.error("Path not allowed: %s", image_path) + return - self._store_auth_token(_client.token) + # Get required image metadata. + image = await self.hass.async_add_executor_job(Image.open, image_path) + (width, height) = image.size + mime_type = mimetypes.guess_type(image_path)[0] + file_stat = await aiofiles.os.stat(image_path) + + _LOGGER.debug("Uploading file from path, %s", image_path) + async with aiofiles.open(image_path, "r+b") as image_file: + response, _ = await self._client.upload( + image_file, + content_type=mime_type, + filename=os.path.basename(image_path), + filesize=file_stat.st_size, + ) + if isinstance(response, UploadError): + _LOGGER.error("Unable to upload image to the homeserver: %s", response) + return + if isinstance(response, UploadResponse): + _LOGGER.debug("Successfully uploaded image to the homeserver") + else: + _LOGGER.error( + "Unknown response received when uploading image to homeserver: %s", + response, + ) + return - return _client + content = { + "body": os.path.basename(image_path), + "info": { + "size": file_stat.st_size, + "mimetype": mime_type, + "w": width, + "h": height, + }, + "msgtype": "m.image", + "url": response.content_uri, + } - def _send_image(self, img, target_rooms): - _LOGGER.debug("Uploading file from path, %s", img) + await self._handle_multi_room_send( + target_rooms=target_rooms, message_type="m.room.message", content=content + ) - if not self.hass.config.is_allowed_path(img): - _LOGGER.error("Path not allowed: %s", img) - return - with open(img, "rb") as upfile: - imgfile = upfile.read() - content_type = mimetypes.guess_type(img)[0] - mxc = self._client.upload(imgfile, content_type) - for target_room in target_rooms: - try: - room = self._join_or_get_room(target_room) - room.send_image(mxc, img, mimetype=content_type) - except MatrixRequestError as ex: - _LOGGER.error( - "Unable to deliver message to room '%s': %d, %s", - target_room, - ex.code, - ex.content, - ) + async def _send_message( + self, message: str, target_rooms: list[RoomID], data: dict | None + ) -> None: + """Send a message to the Matrix server.""" + content = {"msgtype": "m.text", "body": message} + if data is not None and data.get(ATTR_FORMAT) == FORMAT_HTML: + content |= {"format": "org.matrix.custom.html", "formatted_body": message} - def _send_message(self, message, data, target_rooms): - """Send the message to the Matrix server.""" - for target_room in target_rooms: - try: - room = self._join_or_get_room(target_room) - if message is not None: - if data.get(ATTR_FORMAT) == FORMAT_HTML: - _LOGGER.debug(room.send_html(message)) - else: - _LOGGER.debug(room.send_text(message)) - except MatrixRequestError as ex: - _LOGGER.error( - "Unable to deliver message to room '%s': %d, %s", - target_room, - ex.code, - ex.content, - ) - if ATTR_IMAGES in data: - for img in data.get(ATTR_IMAGES, []): - self._send_image(img, target_rooms) + await self._handle_multi_room_send( + target_rooms=target_rooms, message_type="m.room.message", content=content + ) - def handle_send_message(self, service: ServiceCall) -> None: + if ( + data is not None + and (image_paths := data.get(ATTR_IMAGES, [])) + and len(target_rooms) > 0 + ): + image_tasks = [ + self.hass.async_create_task(self._send_image(image_path, target_rooms)) + for image_path in image_paths + ] + await asyncio.wait(image_tasks) + + async def handle_send_message(self, service: ServiceCall) -> None: """Handle the send_message service.""" - self._send_message( - service.data.get(ATTR_MESSAGE), - service.data.get(ATTR_DATA), + await self._send_message( + service.data[ATTR_MESSAGE], service.data[ATTR_TARGET], + service.data.get(ATTR_DATA), ) diff --git a/homeassistant/components/matrix/manifest.json b/homeassistant/components/matrix/manifest.json index 4bded80a71164e..74bb97d10fca95 100644 --- a/homeassistant/components/matrix/manifest.json +++ b/homeassistant/components/matrix/manifest.json @@ -1,9 +1,9 @@ { "domain": "matrix", "name": "Matrix", - "codeowners": [], + "codeowners": ["@PaarthShah"], "documentation": "https://www.home-assistant.io/integrations/matrix", "iot_class": "cloud_push", "loggers": ["matrix_client"], - "requirements": ["matrix-client==0.4.0"] + "requirements": ["matrix-nio==0.21.2", "Pillow==10.0.0"] } diff --git a/homeassistant/components/matrix/notify.py b/homeassistant/components/matrix/notify.py index 3c90e9afbc04c9..c71f91eb582ab1 100644 --- a/homeassistant/components/matrix/notify.py +++ b/homeassistant/components/matrix/notify.py @@ -1,6 +1,8 @@ """Support for Matrix notifications.""" from __future__ import annotations +from typing import Any + import voluptuous as vol from homeassistant.components.notify import ( @@ -14,6 +16,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from . import RoomID from .const import DOMAIN, SERVICE_SEND_MESSAGE CONF_DEFAULT_ROOM = "default_room" @@ -33,16 +36,14 @@ def get_service( class MatrixNotificationService(BaseNotificationService): """Send notifications to a Matrix room.""" - def __init__(self, default_room): + def __init__(self, default_room: RoomID) -> None: """Set up the Matrix notification service.""" self._default_room = default_room - def send_message(self, message="", **kwargs): + def send_message(self, message: str = "", **kwargs: Any) -> None: """Send the message to the Matrix server.""" - target_rooms = kwargs.get(ATTR_TARGET) or [self._default_room] + target_rooms: list[RoomID] = kwargs.get(ATTR_TARGET) or [self._default_room] service_data = {ATTR_TARGET: target_rooms, ATTR_MESSAGE: message} if (data := kwargs.get(ATTR_DATA)) is not None: service_data[ATTR_DATA] = data - return self.hass.services.call( - DOMAIN, SERVICE_SEND_MESSAGE, service_data=service_data - ) + self.hass.services.call(DOMAIN, SERVICE_SEND_MESSAGE, service_data=service_data) diff --git a/homeassistant/components/matter/event.py b/homeassistant/components/matter/event.py index 3a1faa6dcbef84..84049301296b10 100644 --- a/homeassistant/components/matter/event.py +++ b/homeassistant/components/matter/event.py @@ -65,7 +65,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: if feature_map & SwitchFeature.kMomentarySwitchRelease: event_types.append("short_release") if feature_map & SwitchFeature.kMomentarySwitchLongPress: - event_types.append("long_press_ongoing") + event_types.append("long_press") event_types.append("long_release") if feature_map & SwitchFeature.kMomentarySwitchMultiPress: event_types.append("multi_press_ongoing") diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index 2acb516fa95b97..f3ff925a1a4699 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -27,6 +27,7 @@ from homeassistant.components.websocket_api import ERR_NOT_SUPPORTED, ERR_UNKNOWN_ERROR from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( # noqa: F401 + ATTR_ENTITY_PICTURE, SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PAUSE, SERVICE_MEDIA_PLAY, @@ -458,6 +459,17 @@ class MediaPlayerEntityDescription(EntityDescription): class MediaPlayerEntity(Entity): """ABC for media player entities.""" + _entity_component_unrecorded_attributes = frozenset( + { + ATTR_ENTITY_PICTURE_LOCAL, + ATTR_ENTITY_PICTURE, + ATTR_INPUT_SOURCE_LIST, + ATTR_MEDIA_POSITION_UPDATED_AT, + ATTR_MEDIA_POSITION, + ATTR_SOUND_MODE_LIST, + } + ) + entity_description: MediaPlayerEntityDescription _access_token: str | None = None diff --git a/homeassistant/components/media_player/recorder.py b/homeassistant/components/media_player/recorder.py deleted file mode 100644 index 8ced833ebecda9..00000000000000 --- a/homeassistant/components/media_player/recorder.py +++ /dev/null @@ -1,26 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.const import ATTR_ENTITY_PICTURE -from homeassistant.core import HomeAssistant, callback - -from . import ( - ATTR_ENTITY_PICTURE_LOCAL, - ATTR_INPUT_SOURCE_LIST, - ATTR_MEDIA_POSITION, - ATTR_MEDIA_POSITION_UPDATED_AT, - ATTR_SOUND_MODE_LIST, -) - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude static and token attributes from being recorded in the database.""" - return { - ATTR_ENTITY_PICTURE_LOCAL, - ATTR_ENTITY_PICTURE, - ATTR_INPUT_SOURCE_LIST, - ATTR_MEDIA_POSITION_UPDATED_AT, - ATTR_MEDIA_POSITION, - ATTR_SOUND_MODE_LIST, - } diff --git a/homeassistant/components/met/weather.py b/homeassistant/components/met/weather.py index a5a0d34d4ebf6f..a1cc1ade8e14c0 100644 --- a/homeassistant/components/met/weather.py +++ b/homeassistant/components/met/weather.py @@ -2,7 +2,7 @@ from __future__ import annotations from types import MappingProxyType -from typing import Any +from typing import TYPE_CHECKING, Any from homeassistant.components.weather import ( ATTR_FORECAST_CONDITION, @@ -51,11 +51,16 @@ async def async_setup_entry( coordinator: MetDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] entity_registry = er.async_get(hass) - entities = [ - MetWeather( - coordinator, config_entry.data, hass.config.units is METRIC_SYSTEM, False - ) - ] + name: str | None + is_metric = hass.config.units is METRIC_SYSTEM + if config_entry.data.get(CONF_TRACK_HOME, False): + name = hass.config.location_name + elif (name := config_entry.data.get(CONF_NAME)) and name is None: + name = DEFAULT_NAME + elif TYPE_CHECKING: + assert isinstance(name, str) + + entities = [MetWeather(coordinator, config_entry.data, False, name, is_metric)] # Add hourly entity to legacy config entries if entity_registry.async_get_entity_id( @@ -63,10 +68,9 @@ async def async_setup_entry( DOMAIN, _calculate_unique_id(config_entry.data, True), ): + name = f"{name} hourly" entities.append( - MetWeather( - coordinator, config_entry.data, hass.config.units is METRIC_SYSTEM, True - ) + MetWeather(coordinator, config_entry.data, True, name, is_metric) ) async_add_entities(entities) @@ -111,8 +115,9 @@ def __init__( self, coordinator: MetDataUpdateCoordinator, config: MappingProxyType[str, Any], - is_metric: bool, hourly: bool, + name: str, + is_metric: bool, ) -> None: """Initialise the platform with a data instance and site.""" super().__init__(coordinator) @@ -120,32 +125,17 @@ def __init__( self._config = config self._is_metric = is_metric self._hourly = hourly - - @property - def track_home(self) -> Any | bool: - """Return if we are tracking home.""" - return self._config.get(CONF_TRACK_HOME, False) - - @property - def name(self) -> str: - """Return the name of the sensor.""" - name = self._config.get(CONF_NAME) - name_appendix = "" - if self._hourly: - name_appendix = " hourly" - - if name is not None: - return f"{name}{name_appendix}" - - if self.track_home: - return f"{self.hass.config.location_name}{name_appendix}" - - return f"{DEFAULT_NAME}{name_appendix}" - - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - return not self._hourly + self._attr_entity_registry_enabled_default = not hourly + self._attr_device_info = DeviceInfo( + name="Forecast", + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN,)}, # type: ignore[arg-type] + manufacturer="Met.no", + model="Forecast", + configuration_url="https://www.met.no/en", + ) + self._attr_track_home = self._config.get(CONF_TRACK_HOME, False) + self._attr_name = name @property def condition(self) -> str | None: @@ -248,15 +238,3 @@ def _async_forecast_daily(self) -> list[Forecast] | None: def _async_forecast_hourly(self) -> list[Forecast] | None: """Return the hourly forecast in native units.""" return self._forecast(True) - - @property - def device_info(self) -> DeviceInfo: - """Device info.""" - return DeviceInfo( - name="Forecast", - entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN,)}, # type: ignore[arg-type] - manufacturer="Met.no", - model="Forecast", - configuration_url="https://www.met.no/en", - ) diff --git a/homeassistant/components/met_eireann/weather.py b/homeassistant/components/met_eireann/weather.py index 3a45a74c36b8af..7602dca8343294 100644 --- a/homeassistant/components/met_eireann/weather.py +++ b/homeassistant/components/met_eireann/weather.py @@ -94,24 +94,20 @@ def __init__(self, coordinator, config, hourly): self._attr_unique_id = _calculate_unique_id(config, hourly) self._config = config self._hourly = hourly - - @property - def name(self): - """Return the name of the sensor.""" - name = self._config.get(CONF_NAME) - name_appendix = "" - if self._hourly: - name_appendix = " Hourly" - - if name is not None: - return f"{name}{name_appendix}" - - return f"{DEFAULT_NAME}{name_appendix}" - - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - return not self._hourly + name_appendix = " Hourly" if hourly else "" + if (name := self._config.get(CONF_NAME)) is not None: + self._attr_name = f"{name}{name_appendix}" + else: + self._attr_name = f"{DEFAULT_NAME}{name_appendix}" + self._attr_entity_registry_enabled_default = not hourly + self._attr_device_info = DeviceInfo( + name="Forecast", + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN,)}, + manufacturer="Met Éireann", + model="Forecast", + configuration_url="https://www.met.ie", + ) @property def condition(self): @@ -191,15 +187,3 @@ def _async_forecast_daily(self) -> list[Forecast]: def _async_forecast_hourly(self) -> list[Forecast]: """Return the hourly forecast in native units.""" return self._forecast(True) - - @property - def device_info(self): - """Device info.""" - return DeviceInfo( - name="Forecast", - entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN,)}, - manufacturer="Met Éireann", - model="Forecast", - configuration_url="https://www.met.ie", - ) diff --git a/homeassistant/components/meteo_france/sensor.py b/homeassistant/components/meteo_france/sensor.py index 98cb4665614f59..dd8fd4af83b0c2 100644 --- a/homeassistant/components/meteo_france/sensor.py +++ b/homeassistant/components/meteo_france/sensor.py @@ -196,9 +196,9 @@ async def async_setup_entry( data = hass.data[DOMAIN][entry.entry_id] coordinator_forecast: DataUpdateCoordinator[Forecast] = data[COORDINATOR_FORECAST] coordinator_rain: DataUpdateCoordinator[Rain] | None = data[COORDINATOR_RAIN] - coordinator_alert: DataUpdateCoordinator[CurrentPhenomenons] | None = data[ + coordinator_alert: DataUpdateCoordinator[CurrentPhenomenons] | None = data.get( COORDINATOR_ALERT - ] + ) entities: list[MeteoFranceSensor[Any]] = [ MeteoFranceSensor(coordinator_forecast, description) diff --git a/homeassistant/components/meteoclimatic/sensor.py b/homeassistant/components/meteoclimatic/sensor.py index ed37c6d98eae95..9a54e766945b80 100644 --- a/homeassistant/components/meteoclimatic/sensor.py +++ b/homeassistant/components/meteoclimatic/sensor.py @@ -3,6 +3,7 @@ SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -29,6 +30,7 @@ name="Temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="temp_max", @@ -47,6 +49,7 @@ name="Humidity", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="humidity_max", @@ -65,6 +68,7 @@ name="Pressure", native_unit_of_measurement=UnitOfPressure.HPA, device_class=SensorDeviceClass.PRESSURE, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="pressure_max", @@ -83,6 +87,7 @@ name="Wind Speed", native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, device_class=SensorDeviceClass.WIND_SPEED, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="wind_max", diff --git a/homeassistant/components/mikrotik/strings.json b/homeassistant/components/mikrotik/strings.json index ec47d98b7a9f5e..582450eca62b21 100644 --- a/homeassistant/components/mikrotik/strings.json +++ b/homeassistant/components/mikrotik/strings.json @@ -9,7 +9,7 @@ "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", "port": "[%key:common::config_flow::data::port%]", - "verify_ssl": "Use ssl" + "verify_ssl": "[%key:common::config_flow::data::ssl%]" } }, "reauth_confirm": { diff --git a/homeassistant/components/mill/manifest.json b/homeassistant/components/mill/manifest.json index 39b91570190c0e..561a24c29dfe74 100644 --- a/homeassistant/components/mill/manifest.json +++ b/homeassistant/components/mill/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/mill", "iot_class": "local_polling", "loggers": ["mill", "mill_local"], - "requirements": ["millheater==0.11.1", "mill-local==0.3.0"] + "requirements": ["millheater==0.11.5", "mill-local==0.3.0"] } diff --git a/homeassistant/components/minecraft_server/__init__.py b/homeassistant/components/minecraft_server/__init__.py index cf0d96af8d25ef..b7326735be9bfc 100644 --- a/homeassistant/components/minecraft_server/__init__.py +++ b/homeassistant/components/minecraft_server/__init__.py @@ -1,22 +1,17 @@ """The Minecraft Server integration.""" from __future__ import annotations -from collections.abc import Mapping -from dataclasses import dataclass -from datetime import datetime, timedelta import logging from typing import Any -from mcstatus.server import JavaServer - from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, Platform -from homeassistant.core import CALLBACK_TYPE, HomeAssistant -from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.event import async_track_time_interval +from homeassistant.const import CONF_HOST, CONF_NAME, Platform +from homeassistant.core import HomeAssistant, callback +import homeassistant.helpers.device_registry as dr +import homeassistant.helpers.entity_registry as er -from . import helpers -from .const import DOMAIN, SCAN_INTERVAL, SIGNAL_NAME_PREFIX +from .const import DOMAIN, KEY_LATENCY, KEY_MOTD +from .coordinator import MinecraftServerCoordinator PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] @@ -25,20 +20,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Minecraft Server from a config entry.""" - domain_data = hass.data.setdefault(DOMAIN, {}) - - # Create and store server instance. - assert entry.unique_id - unique_id = entry.unique_id _LOGGER.debug( - "Creating server instance for '%s' (%s)", + "Creating coordinator instance for '%s' (%s)", entry.data[CONF_NAME], entry.data[CONF_HOST], ) - server = MinecraftServer(hass, unique_id, entry.data) - domain_data[unique_id] = server - await server.async_update() - server.start_periodic_update() + + # Create coordinator instance. + config_entry_id = entry.entry_id + coordinator = MinecraftServerCoordinator(hass, config_entry_id, entry.data) + await coordinator.async_config_entry_first_refresh() + + # Store coordinator instance. + domain_data = hass.data.setdefault(DOMAIN, {}) + domain_data[config_entry_id] = coordinator # Set up platforms. await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -48,8 +43,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Unload Minecraft Server config entry.""" - unique_id = config_entry.unique_id - server = hass.data[DOMAIN][unique_id] + config_entry_id = config_entry.entry_id # Unload platforms. unload_ok = await hass.config_entries.async_unload_platforms( @@ -57,164 +51,102 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> ) # Clean up. - server.stop_periodic_update() - hass.data[DOMAIN].pop(unique_id) + hass.data[DOMAIN].pop(config_entry_id) return unload_ok -@dataclass -class MinecraftServerData: - """Representation of Minecraft server data.""" - - latency: float | None = None - motd: str | None = None - players_max: int | None = None - players_online: int | None = None - players_list: list[str] | None = None - protocol_version: int | None = None - version: str | None = None - - -class MinecraftServer: - """Representation of a Minecraft server.""" - - def __init__( - self, hass: HomeAssistant, unique_id: str, config_data: Mapping[str, Any] - ) -> None: - """Initialize server instance.""" - self._hass = hass - - # Server data - self.unique_id = unique_id - self.name = config_data[CONF_NAME] - self.host = config_data[CONF_HOST] - self.port = config_data[CONF_PORT] - self.online = False - self._last_status_request_failed = False - self.srv_record_checked = False - - # 3rd party library instance - self._server = JavaServer(self.host, self.port) - - # Data provided by 3rd party library - self.data: MinecraftServerData = MinecraftServerData() - - # Dispatcher signal name - self.signal_name = f"{SIGNAL_NAME_PREFIX}_{self.unique_id}" - - # Callback for stopping periodic update. - self._stop_periodic_update: CALLBACK_TYPE | None = None - - def start_periodic_update(self) -> None: - """Start periodic execution of update method.""" - self._stop_periodic_update = async_track_time_interval( - self._hass, self.async_update, timedelta(seconds=SCAN_INTERVAL) - ) - - def stop_periodic_update(self) -> None: - """Stop periodic execution of update method.""" - if self._stop_periodic_update: - self._stop_periodic_update() - - async def async_check_connection(self) -> None: - """Check server connection using a 'status' request and store connection status.""" - # Check if host is a valid SRV record, if not already done. - if not self.srv_record_checked: - self.srv_record_checked = True - srv_record = await helpers.async_check_srv_record(self._hass, self.host) - if srv_record is not None: +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate old config entry to a new format.""" + _LOGGER.debug("Migrating from version %s", config_entry.version) + + # 1 --> 2: Use config entry ID as base for unique IDs. + if config_entry.version == 1: + old_unique_id = config_entry.unique_id + assert old_unique_id + config_entry_id = config_entry.entry_id + + # Migrate config entry. + _LOGGER.debug("Migrating config entry. Resetting unique ID: %s", old_unique_id) + config_entry.unique_id = None + config_entry.version = 2 + hass.config_entries.async_update_entry(config_entry) + + # Migrate device. + await _async_migrate_device_identifiers(hass, config_entry, old_unique_id) + + # Migrate entities. + await er.async_migrate_entries(hass, config_entry_id, _migrate_entity_unique_id) + + _LOGGER.debug("Migration to version %s successful", config_entry.version) + + return True + + +async def _async_migrate_device_identifiers( + hass: HomeAssistant, config_entry: ConfigEntry, old_unique_id: str | None +) -> None: + """Migrate the device identifiers to the new format.""" + device_registry = dr.async_get(hass) + device_entry_found = False + for device_entry in dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ): + for identifier in device_entry.identifiers: + if identifier[1] == old_unique_id: + # Device found in registry. Update identifiers. + new_identifiers = { + ( + DOMAIN, + config_entry.entry_id, + ) + } _LOGGER.debug( - "'%s' is a valid Minecraft SRV record ('%s:%s')", - self.host, - srv_record[CONF_HOST], - srv_record[CONF_PORT], - ) - # Overwrite host, port and 3rd party library instance - # with data extracted out of SRV record. - self.host = srv_record[CONF_HOST] - self.port = srv_record[CONF_PORT] - self._server = JavaServer(self.host, self.port) - - # Ping the server with a status request. - try: - await self._server.async_status() - self.online = True - except OSError as error: - _LOGGER.debug( - ( - "Error occurred while trying to check the connection to '%s:%s' -" - " OSError: %s" - ), - self.host, - self.port, - error, - ) - self.online = False - - async def async_update(self, now: datetime | None = None) -> None: - """Get server data from 3rd party library and update properties.""" - # Check connection status. - server_online_old = self.online - await self.async_check_connection() - server_online = self.online - - # Inform user once about connection state changes if necessary. - if server_online_old and not server_online: - _LOGGER.warning("Connection to '%s:%s' lost", self.host, self.port) - elif not server_online_old and server_online: - _LOGGER.info("Connection to '%s:%s' (re-)established", self.host, self.port) - - # Update the server properties if server is online. - if server_online: - await self._async_status_request() - - # Notify sensors about new data. - async_dispatcher_send(self._hass, self.signal_name) - - async def _async_status_request(self) -> None: - """Request server status and update properties.""" - try: - status_response = await self._server.async_status() - - # Got answer to request, update properties. - self.data.version = status_response.version.name - self.data.protocol_version = status_response.version.protocol - self.data.players_online = status_response.players.online - self.data.players_max = status_response.players.max - self.data.latency = status_response.latency - self.data.motd = status_response.motd.to_plain() - - self.data.players_list = [] - if status_response.players.sample is not None: - for player in status_response.players.sample: - self.data.players_list.append(player.name) - self.data.players_list.sort() - - # Inform user once about successful update if necessary. - if self._last_status_request_failed: - _LOGGER.info( - "Updating the properties of '%s:%s' succeeded again", - self.host, - self.port, + "Migrating device identifiers from %s to %s", + device_entry.identifiers, + new_identifiers, ) - self._last_status_request_failed = False - except OSError as error: - # No answer to request, set all properties to unknown. - self.data.version = None - self.data.protocol_version = None - self.data.players_online = None - self.data.players_max = None - self.data.latency = None - self.data.players_list = None - self.data.motd = None - - # Inform user once about failed update if necessary. - if not self._last_status_request_failed: - _LOGGER.warning( - "Updating the properties of '%s:%s' failed - OSError: %s", - self.host, - self.port, - error, + device_registry.async_update_device( + device_id=device_entry.id, new_identifiers=new_identifiers ) - self._last_status_request_failed = True + # Device entry found. Leave inner for loop. + device_entry_found = True + break + + # Leave outer for loop if device entry is already found. + if device_entry_found: + break + + +@callback +def _migrate_entity_unique_id(entity_entry: er.RegistryEntry) -> dict[str, Any]: + """Migrate the unique ID of an entity to the new format.""" + + # Different variants of unique IDs are available in version 1: + # 1) SRV record: '-srv-' + # 2) Host & port: '--' + # 3) IP address & port: '--' + unique_id_pieces = entity_entry.unique_id.split("-") + entity_type = unique_id_pieces[2] + + # Handle bug in version 1: Entity type names were used instead of + # keys (e.g. "Protocol Version" instead of "protocol_version"). + new_entity_type = entity_type.lower() + new_entity_type = new_entity_type.replace(" ", "_") + + # Special case 'MOTD': Name and key differs. + if new_entity_type == "world_message": + new_entity_type = KEY_MOTD + + # Special case 'latency_time': Renamed to 'latency'. + if new_entity_type == "latency_time": + new_entity_type = KEY_LATENCY + + new_unique_id = f"{entity_entry.config_entry_id}-{new_entity_type}" + _LOGGER.debug( + "Migrating entity unique ID from %s to %s", + entity_entry.unique_id, + new_unique_id, + ) + + return {"new_unique_id": new_unique_id} diff --git a/homeassistant/components/minecraft_server/binary_sensor.py b/homeassistant/components/minecraft_server/binary_sensor.py index 3589bfab3e2932..e89fce2d7d5a8f 100644 --- a/homeassistant/components/minecraft_server/binary_sensor.py +++ b/homeassistant/components/minecraft_server/binary_sensor.py @@ -1,16 +1,38 @@ """The Minecraft Server binary sensor platform.""" +from dataclasses import dataclass + from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, + BinarySensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import MinecraftServer -from .const import DOMAIN, ICON_STATUS, KEY_STATUS, NAME_STATUS +from .const import DOMAIN +from .coordinator import MinecraftServerCoordinator from .entity import MinecraftServerEntity +ICON_STATUS = "mdi:lan" + +KEY_STATUS = "status" + + +@dataclass +class MinecraftServerBinarySensorEntityDescription(BinarySensorEntityDescription): + """Class describing Minecraft Server binary sensor entities.""" + + +BINARY_SENSOR_DESCRIPTIONS = [ + MinecraftServerBinarySensorEntityDescription( + key=KEY_STATUS, + translation_key=KEY_STATUS, + device_class=BinarySensorDeviceClass.CONNECTIVITY, + icon=ICON_STATUS, + ), +] + async def async_setup_entry( hass: HomeAssistant, @@ -18,30 +40,39 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Minecraft Server binary sensor platform.""" - server = hass.data[DOMAIN][config_entry.unique_id] - - # Create entities list. - entities = [MinecraftServerStatusBinarySensor(server)] + coordinator = hass.data[DOMAIN][config_entry.entry_id] # Add binary sensor entities. - async_add_entities(entities, True) + async_add_entities( + [ + MinecraftServerBinarySensorEntity(coordinator, description) + for description in BINARY_SENSOR_DESCRIPTIONS + ] + ) -class MinecraftServerStatusBinarySensor(MinecraftServerEntity, BinarySensorEntity): - """Representation of a Minecraft Server status binary sensor.""" +class MinecraftServerBinarySensorEntity(MinecraftServerEntity, BinarySensorEntity): + """Representation of a Minecraft Server binary sensor base entity.""" - _attr_translation_key = KEY_STATUS + entity_description: MinecraftServerBinarySensorEntityDescription - def __init__(self, server: MinecraftServer) -> None: - """Initialize status binary sensor.""" - super().__init__( - server=server, - type_name=NAME_STATUS, - icon=ICON_STATUS, - device_class=BinarySensorDeviceClass.CONNECTIVITY, - ) + def __init__( + self, + coordinator: MinecraftServerCoordinator, + description: MinecraftServerBinarySensorEntityDescription, + ) -> None: + """Initialize binary sensor base entity.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.unique_id}-{description.key}" self._attr_is_on = False - async def async_update(self) -> None: - """Update status.""" - self._attr_is_on = self._server.online + @property + def available(self) -> bool: + """Return binary sensor availability.""" + return True + + @property + def is_on(self) -> bool: + """Return binary sensor state.""" + return self.coordinator.last_update_success diff --git a/homeassistant/components/minecraft_server/config_flow.py b/homeassistant/components/minecraft_server/config_flow.py index c8429284cd8cb5..f4b4212bc64bef 100644 --- a/homeassistant/components/minecraft_server/config_flow.py +++ b/homeassistant/components/minecraft_server/config_flow.py @@ -1,23 +1,26 @@ """Config flow for Minecraft Server integration.""" from contextlib import suppress -from functools import partial -import ipaddress +import logging -import getmac +from mcstatus import JavaServer import voluptuous as vol from homeassistant.config_entries import ConfigFlow from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT from homeassistant.data_entry_flow import FlowResult -from . import MinecraftServer, helpers -from .const import DEFAULT_HOST, DEFAULT_NAME, DEFAULT_PORT, DOMAIN +from . import helpers +from .const import DEFAULT_NAME, DEFAULT_PORT, DOMAIN + +DEFAULT_HOST = "localhost:25565" + +_LOGGER = logging.getLogger(__name__) class MinecraftServerConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Minecraft Server.""" - VERSION = 1 + VERSION = 2 async def async_step_user(self, user_input=None) -> FlowResult: """Handle the initial step.""" @@ -26,10 +29,13 @@ async def async_step_user(self, user_input=None) -> FlowResult: if user_input is not None: host = None port = DEFAULT_PORT + title = user_input[CONF_HOST] + # Split address at last occurrence of ':'. address_left, separator, address_right = user_input[CONF_HOST].rpartition( ":" ) + # If no separator is found, 'rpartition' returns ('', '', original_string). if separator == "": host = address_right @@ -41,32 +47,8 @@ async def async_step_user(self, user_input=None) -> FlowResult: # Remove '[' and ']' in case of an IPv6 address. host = host.strip("[]") - # Check if 'host' is a valid IP address and if so, get the MAC address. - ip_address = None - mac_address = None - try: - ip_address = ipaddress.ip_address(host) - except ValueError: - # Host is not a valid IP address. - # Continue with host and port. - pass - else: - # Host is a valid IP address. - if ip_address.version == 4: - # Address type is IPv4. - params = {"ip": host} - else: - # Address type is IPv6. - params = {"ip6": host} - mac_address = await self.hass.async_add_executor_job( - partial(getmac.get_mac_address, **params) - ) - - # Validate IP address (MAC address must be available). - if ip_address is not None and mac_address is None: - errors["base"] = "invalid_ip" # Validate port configuration (limit to user and dynamic port range). - elif (port < 1024) or (port > 65535): + if (port < 1024) or (port > 65535): errors["base"] = "invalid_port" # Validate host and port by checking the server connection. else: @@ -76,44 +58,14 @@ async def async_step_user(self, user_input=None) -> FlowResult: CONF_HOST: host, CONF_PORT: port, } - server = MinecraftServer(self.hass, "dummy_unique_id", config_data) - await server.async_check_connection() - if not server.online: - # Host or port invalid or server not reachable. - errors["base"] = "cannot_connect" - else: - # Build unique_id and config entry title. - unique_id = "" - title = f"{host}:{port}" - if ip_address is not None: - # Since IP addresses can change and therefore are not allowed - # in a unique_id, fall back to the MAC address and port (to - # support servers with same MAC address but different ports). - unique_id = f"{mac_address}-{port}" - if ip_address.version == 6: - title = f"[{host}]:{port}" - else: - # Check if 'host' is a valid SRV record. - srv_record = await helpers.async_check_srv_record( - self.hass, host - ) - if srv_record is not None: - # Use only SRV host name in unique_id (does not change). - unique_id = f"{host}-srv" - title = host - else: - # Use host name and port in unique_id (to support servers - # with same host name but different ports). - unique_id = f"{host}-{port}" - - # Abort in case the host was already configured before. - await self.async_set_unique_id(unique_id) - self._abort_if_unique_id_configured() - + if await self._async_is_server_online(host, port): # Configuration data are available and no error was detected, # create configuration entry. return self.async_create_entry(title=title, data=config_data) + # Host or port invalid or server not reachable. + errors["base"] = "cannot_connect" + # Show configuration form (default form in case of no user_input, # form filled with user_input and eventually with errors otherwise). return self._show_config_form(user_input, errors) @@ -137,3 +89,30 @@ def _show_config_form(self, user_input=None, errors=None) -> FlowResult: ), errors=errors, ) + + async def _async_is_server_online(self, host: str, port: int) -> bool: + """Check server connection using a 'status' request and return result.""" + + # Check if host is a SRV record. If so, update server data. + if srv_record := await helpers.async_check_srv_record(host): + # Use extracted host and port from SRV record. + host = srv_record[CONF_HOST] + port = srv_record[CONF_PORT] + + # Send a status request to the server. + server = JavaServer(host, port) + try: + await server.async_status() + return True + except OSError as error: + _LOGGER.debug( + ( + "Error occurred while trying to check the connection to '%s:%s' -" + " OSError: %s" + ), + host, + port, + error, + ) + + return False diff --git a/homeassistant/components/minecraft_server/const.py b/homeassistant/components/minecraft_server/const.py index 72a891138c4b39..9f14f429a12c1b 100644 --- a/homeassistant/components/minecraft_server/const.py +++ b/homeassistant/components/minecraft_server/const.py @@ -1,44 +1,9 @@ """Constants for the Minecraft Server integration.""" -ATTR_PLAYERS_LIST = "players_list" - -DEFAULT_HOST = "localhost:25565" DEFAULT_NAME = "Minecraft Server" DEFAULT_PORT = 25565 DOMAIN = "minecraft_server" -ICON_LATENCY = "mdi:signal" -ICON_PLAYERS_MAX = "mdi:account-multiple" -ICON_PLAYERS_ONLINE = "mdi:account-multiple" -ICON_PROTOCOL_VERSION = "mdi:numeric" -ICON_STATUS = "mdi:lan" -ICON_VERSION = "mdi:numeric" -ICON_MOTD = "mdi:minecraft" - KEY_LATENCY = "latency" -KEY_PLAYERS_MAX = "players_max" -KEY_PLAYERS_ONLINE = "players_online" -KEY_PROTOCOL_VERSION = "protocol_version" -KEY_STATUS = "status" -KEY_VERSION = "version" KEY_MOTD = "motd" - -MANUFACTURER = "Mojang AB" - -NAME_LATENCY = "Latency Time" -NAME_PLAYERS_MAX = "Players Max" -NAME_PLAYERS_ONLINE = "Players Online" -NAME_PROTOCOL_VERSION = "Protocol Version" -NAME_STATUS = "Status" -NAME_VERSION = "Version" -NAME_MOTD = "World Message" - -SCAN_INTERVAL = 60 - -SIGNAL_NAME_PREFIX = f"signal_{DOMAIN}" - -SRV_RECORD_PREFIX = "_minecraft._tcp" - -UNIT_PLAYERS_MAX = "players" -UNIT_PLAYERS_ONLINE = "players" diff --git a/homeassistant/components/minecraft_server/coordinator.py b/homeassistant/components/minecraft_server/coordinator.py new file mode 100644 index 00000000000000..178c12772c6686 --- /dev/null +++ b/homeassistant/components/minecraft_server/coordinator.py @@ -0,0 +1,94 @@ +"""The Minecraft Server integration.""" +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +from datetime import timedelta +import logging +from typing import Any + +from mcstatus.server import JavaServer + +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from . import helpers + +SCAN_INTERVAL = timedelta(seconds=60) + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class MinecraftServerData: + """Representation of Minecraft Server data.""" + + latency: float + motd: str + players_max: int + players_online: int + players_list: list[str] + protocol_version: int + version: str + + +class MinecraftServerCoordinator(DataUpdateCoordinator[MinecraftServerData]): + """Minecraft Server data update coordinator.""" + + _srv_record_checked = False + + def __init__( + self, hass: HomeAssistant, unique_id: str, config_data: Mapping[str, Any] + ) -> None: + """Initialize coordinator instance.""" + super().__init__( + hass=hass, + name=config_data[CONF_NAME], + logger=_LOGGER, + update_interval=SCAN_INTERVAL, + ) + + # Server data + self.unique_id = unique_id + self._host = config_data[CONF_HOST] + self._port = config_data[CONF_PORT] + + # 3rd party library instance + self._server = JavaServer(self._host, self._port) + + async def _async_update_data(self) -> MinecraftServerData: + """Get server data from 3rd party library and update properties.""" + + # Check once if host is a valid Minecraft SRV record. + if not self._srv_record_checked: + self._srv_record_checked = True + if srv_record := await helpers.async_check_srv_record(self._host): + # Overwrite host, port and 3rd party library instance + # with data extracted out of the SRV record. + self._host = srv_record[CONF_HOST] + self._port = srv_record[CONF_PORT] + self._server = JavaServer(self._host, self._port) + + # Send status request to the server. + try: + status_response = await self._server.async_status() + except OSError as error: + raise UpdateFailed(error) from error + + # Got answer to request, update properties. + players_list = [] + if players := status_response.players.sample: + for player in players: + players_list.append(player.name) + players_list.sort() + + return MinecraftServerData( + version=status_response.version.name, + protocol_version=status_response.version.protocol, + players_online=status_response.players.online, + players_max=status_response.players.max, + players_list=players_list, + latency=status_response.latency, + motd=status_response.motd.to_plain(), + ) diff --git a/homeassistant/components/minecraft_server/entity.py b/homeassistant/components/minecraft_server/entity.py index 63d68d0aa77891..9bac71e00005ba 100644 --- a/homeassistant/components/minecraft_server/entity.py +++ b/homeassistant/components/minecraft_server/entity.py @@ -1,58 +1,29 @@ """Base entity for the Minecraft Server integration.""" -from homeassistant.core import CALLBACK_TYPE, callback from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import MinecraftServer -from .const import DOMAIN, MANUFACTURER +from .const import DOMAIN +from .coordinator import MinecraftServerCoordinator +MANUFACTURER = "Mojang Studios" -class MinecraftServerEntity(Entity): + +class MinecraftServerEntity(CoordinatorEntity[MinecraftServerCoordinator]): """Representation of a Minecraft Server base entity.""" _attr_has_entity_name = True - _attr_should_poll = False def __init__( self, - server: MinecraftServer, - type_name: str, - icon: str, - device_class: str | None, + coordinator: MinecraftServerCoordinator, ) -> None: """Initialize base entity.""" - self._server = server - self._attr_icon = icon - self._attr_unique_id = f"{self._server.unique_id}-{type_name}" + super().__init__(coordinator) self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self._server.unique_id)}, + identifiers={(DOMAIN, coordinator.unique_id)}, manufacturer=MANUFACTURER, - model=f"Minecraft Server ({self._server.data.version})", - name=self._server.name, - sw_version=f"{self._server.data.protocol_version}", - ) - self._attr_device_class = device_class - self._extra_state_attributes = None - self._disconnect_dispatcher: CALLBACK_TYPE | None = None - - async def async_update(self) -> None: - """Fetch data from the server.""" - raise NotImplementedError() - - async def async_added_to_hass(self) -> None: - """Connect dispatcher to signal from server.""" - self._disconnect_dispatcher = async_dispatcher_connect( - self.hass, self._server.signal_name, self._update_callback + model=f"Minecraft Server ({coordinator.data.version})", + name=coordinator.name, + sw_version=str(coordinator.data.protocol_version), ) - - async def async_will_remove_from_hass(self) -> None: - """Disconnect dispatcher before removal.""" - if self._disconnect_dispatcher: - self._disconnect_dispatcher() - - @callback - def _update_callback(self) -> None: - """Triggers update of properties after receiving signal from server.""" - self.async_schedule_update_ha_state(force_refresh=True) diff --git a/homeassistant/components/minecraft_server/helpers.py b/homeassistant/components/minecraft_server/helpers.py index d4a49d96f839f1..f5991620c68515 100644 --- a/homeassistant/components/minecraft_server/helpers.py +++ b/homeassistant/components/minecraft_server/helpers.py @@ -1,35 +1,38 @@ -"""Helper functions for the Minecraft Server integration.""" -from __future__ import annotations - +"""Helper functions of Minecraft Server integration.""" +import logging from typing import Any import aiodns from homeassistant.const import CONF_HOST, CONF_PORT -from homeassistant.core import HomeAssistant -from .const import SRV_RECORD_PREFIX +SRV_RECORD_PREFIX = "_minecraft._tcp" + +_LOGGER = logging.getLogger(__name__) -async def async_check_srv_record( - hass: HomeAssistant, host: str -) -> dict[str, Any] | None: +async def async_check_srv_record(host: str) -> dict[str, Any] | None: """Check if the given host is a valid Minecraft SRV record.""" - # Check if 'host' is a valid SRV record. - return_value = None - srv_records = None + srv_record = None + try: - srv_records = await aiodns.DNSResolver().query( + srv_query = await aiodns.DNSResolver().query( host=f"{SRV_RECORD_PREFIX}.{host}", qtype="SRV" ) except aiodns.error.DNSError: - # 'host' is not a SRV record. + # 'host' is not a Minecraft SRV record. pass else: - # 'host' is a valid SRV record, extract the data. - return_value = { - CONF_HOST: srv_records[0].host, - CONF_PORT: srv_records[0].port, + # 'host' is a valid Minecraft SRV record, extract the data. + srv_record = { + CONF_HOST: srv_query[0].host, + CONF_PORT: srv_query[0].port, } + _LOGGER.debug( + "'%s' is a valid Minecraft SRV record ('%s:%s')", + host, + srv_record[CONF_HOST], + srv_record[CONF_PORT], + ) - return return_value + return srv_record diff --git a/homeassistant/components/minecraft_server/manifest.json b/homeassistant/components/minecraft_server/manifest.json index 27019cb80a8bf4..758f22b1e9a795 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": ["aiodns==3.0.0", "getmac==0.8.2", "mcstatus==11.0.0"] + "requirements": ["aiodns==3.0.0", "mcstatus==11.0.0"] } diff --git a/homeassistant/components/minecraft_server/sensor.py b/homeassistant/components/minecraft_server/sensor.py index 74422675718ac9..efe534e0f92002 100644 --- a/homeassistant/components/minecraft_server/sensor.py +++ b/homeassistant/components/minecraft_server/sensor.py @@ -1,39 +1,116 @@ """The Minecraft Server sensor platform.""" from __future__ import annotations -from homeassistant.components.sensor import SensorEntity +from collections.abc import Callable, MutableMapping +from dataclasses import dataclass +from typing import Any + +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfTime -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType -from . import MinecraftServer -from .const import ( - ATTR_PLAYERS_LIST, - DOMAIN, - ICON_LATENCY, - ICON_MOTD, - ICON_PLAYERS_MAX, - ICON_PLAYERS_ONLINE, - ICON_PROTOCOL_VERSION, - ICON_VERSION, - KEY_LATENCY, - KEY_MOTD, - KEY_PLAYERS_MAX, - KEY_PLAYERS_ONLINE, - KEY_PROTOCOL_VERSION, - KEY_VERSION, - NAME_LATENCY, - NAME_MOTD, - NAME_PLAYERS_MAX, - NAME_PLAYERS_ONLINE, - NAME_PROTOCOL_VERSION, - NAME_VERSION, - UNIT_PLAYERS_MAX, - UNIT_PLAYERS_ONLINE, -) +from .const import DOMAIN, KEY_LATENCY, KEY_MOTD +from .coordinator import MinecraftServerCoordinator, MinecraftServerData from .entity import MinecraftServerEntity +ATTR_PLAYERS_LIST = "players_list" + +ICON_LATENCY = "mdi:signal" +ICON_PLAYERS_MAX = "mdi:account-multiple" +ICON_PLAYERS_ONLINE = "mdi:account-multiple" +ICON_PROTOCOL_VERSION = "mdi:numeric" +ICON_VERSION = "mdi:numeric" +ICON_MOTD = "mdi:minecraft" + +KEY_PLAYERS_MAX = "players_max" +KEY_PLAYERS_ONLINE = "players_online" +KEY_PROTOCOL_VERSION = "protocol_version" +KEY_VERSION = "version" + +UNIT_PLAYERS_MAX = "players" +UNIT_PLAYERS_ONLINE = "players" + + +@dataclass +class MinecraftServerEntityDescriptionMixin: + """Mixin values for Minecraft Server entities.""" + + value_fn: Callable[[MinecraftServerData], StateType] + attributes_fn: Callable[[MinecraftServerData], MutableMapping[str, Any]] | None + + +@dataclass +class MinecraftServerSensorEntityDescription( + SensorEntityDescription, MinecraftServerEntityDescriptionMixin +): + """Class describing Minecraft Server sensor entities.""" + + +def get_extra_state_attributes_players_list( + data: MinecraftServerData, +) -> dict[str, list[str]]: + """Return players list as extra state attributes, if available.""" + extra_state_attributes = {} + players_list = data.players_list + + if players_list is not None and len(players_list) != 0: + extra_state_attributes[ATTR_PLAYERS_LIST] = players_list + + return extra_state_attributes + + +SENSOR_DESCRIPTIONS = [ + MinecraftServerSensorEntityDescription( + key=KEY_VERSION, + translation_key=KEY_VERSION, + icon=ICON_VERSION, + value_fn=lambda data: data.version, + attributes_fn=None, + ), + MinecraftServerSensorEntityDescription( + key=KEY_PROTOCOL_VERSION, + translation_key=KEY_PROTOCOL_VERSION, + icon=ICON_PROTOCOL_VERSION, + value_fn=lambda data: data.protocol_version, + attributes_fn=None, + ), + MinecraftServerSensorEntityDescription( + key=KEY_PLAYERS_MAX, + translation_key=KEY_PLAYERS_MAX, + native_unit_of_measurement=UNIT_PLAYERS_MAX, + icon=ICON_PLAYERS_MAX, + value_fn=lambda data: data.players_max, + attributes_fn=None, + ), + MinecraftServerSensorEntityDescription( + key=KEY_LATENCY, + translation_key=KEY_LATENCY, + native_unit_of_measurement=UnitOfTime.MILLISECONDS, + suggested_display_precision=0, + icon=ICON_LATENCY, + value_fn=lambda data: data.latency, + attributes_fn=None, + ), + MinecraftServerSensorEntityDescription( + key=KEY_MOTD, + translation_key=KEY_MOTD, + icon=ICON_MOTD, + value_fn=lambda data: data.motd, + attributes_fn=None, + ), + MinecraftServerSensorEntityDescription( + key=KEY_PLAYERS_ONLINE, + translation_key=KEY_PLAYERS_ONLINE, + native_unit_of_measurement=UNIT_PLAYERS_ONLINE, + icon=ICON_PLAYERS_ONLINE, + value_fn=lambda data: data.players_online, + attributes_fn=get_extra_state_attributes_players_list, + ), +] + async def async_setup_entry( hass: HomeAssistant, @@ -41,153 +118,45 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Minecraft Server sensor platform.""" - server = hass.data[DOMAIN][config_entry.unique_id] - - # Create entities list. - entities = [ - MinecraftServerVersionSensor(server), - MinecraftServerProtocolVersionSensor(server), - MinecraftServerLatencySensor(server), - MinecraftServerPlayersOnlineSensor(server), - MinecraftServerPlayersMaxSensor(server), - MinecraftServerMOTDSensor(server), - ] + coordinator = hass.data[DOMAIN][config_entry.entry_id] # Add sensor entities. - async_add_entities(entities, True) + async_add_entities( + [ + MinecraftServerSensorEntity(coordinator, description) + for description in SENSOR_DESCRIPTIONS + ] + ) class MinecraftServerSensorEntity(MinecraftServerEntity, SensorEntity): """Representation of a Minecraft Server sensor base entity.""" + entity_description: MinecraftServerSensorEntityDescription + def __init__( self, - server: MinecraftServer, - type_name: str, - icon: str, - unit: str | None = None, - device_class: str | None = None, + coordinator: MinecraftServerCoordinator, + description: MinecraftServerSensorEntityDescription, ) -> None: """Initialize sensor base entity.""" - super().__init__(server, type_name, icon, device_class) - self._attr_native_unit_of_measurement = unit - - @property - def available(self) -> bool: - """Return sensor availability.""" - return self._server.online - - -class MinecraftServerVersionSensor(MinecraftServerSensorEntity): - """Representation of a Minecraft Server version sensor.""" - - _attr_translation_key = KEY_VERSION - - def __init__(self, server: MinecraftServer) -> None: - """Initialize version sensor.""" - super().__init__(server=server, type_name=NAME_VERSION, icon=ICON_VERSION) - - async def async_update(self) -> None: - """Update version.""" - self._attr_native_value = self._server.data.version - - -class MinecraftServerProtocolVersionSensor(MinecraftServerSensorEntity): - """Representation of a Minecraft Server protocol version sensor.""" - - _attr_translation_key = KEY_PROTOCOL_VERSION - - def __init__(self, server: MinecraftServer) -> None: - """Initialize protocol version sensor.""" - super().__init__( - server=server, - type_name=NAME_PROTOCOL_VERSION, - icon=ICON_PROTOCOL_VERSION, - ) - - async def async_update(self) -> None: - """Update protocol version.""" - self._attr_native_value = self._server.data.protocol_version - - -class MinecraftServerLatencySensor(MinecraftServerSensorEntity): - """Representation of a Minecraft Server latency sensor.""" - - _attr_translation_key = KEY_LATENCY - - def __init__(self, server: MinecraftServer) -> None: - """Initialize latency sensor.""" - super().__init__( - server=server, - type_name=NAME_LATENCY, - icon=ICON_LATENCY, - unit=UnitOfTime.MILLISECONDS, - ) - - async def async_update(self) -> None: - """Update latency.""" - self._attr_native_value = self._server.data.latency - - -class MinecraftServerPlayersOnlineSensor(MinecraftServerSensorEntity): - """Representation of a Minecraft Server online players sensor.""" - - _attr_translation_key = KEY_PLAYERS_ONLINE - - def __init__(self, server: MinecraftServer) -> None: - """Initialize online players sensor.""" - super().__init__( - server=server, - type_name=NAME_PLAYERS_ONLINE, - icon=ICON_PLAYERS_ONLINE, - unit=UNIT_PLAYERS_ONLINE, - ) - - async def async_update(self) -> None: - """Update online players state and device state attributes.""" - self._attr_native_value = self._server.data.players_online - - extra_state_attributes = {} - players_list = self._server.data.players_list - - if players_list is not None and len(players_list) != 0: - extra_state_attributes[ATTR_PLAYERS_LIST] = players_list - - self._attr_extra_state_attributes = extra_state_attributes - - -class MinecraftServerPlayersMaxSensor(MinecraftServerSensorEntity): - """Representation of a Minecraft Server maximum number of players sensor.""" - - _attr_translation_key = KEY_PLAYERS_MAX - - def __init__(self, server: MinecraftServer) -> None: - """Initialize maximum number of players sensor.""" - super().__init__( - server=server, - type_name=NAME_PLAYERS_MAX, - icon=ICON_PLAYERS_MAX, - unit=UNIT_PLAYERS_MAX, - ) - - async def async_update(self) -> None: - """Update maximum number of players.""" - self._attr_native_value = self._server.data.players_max - - -class MinecraftServerMOTDSensor(MinecraftServerSensorEntity): - """Representation of a Minecraft Server MOTD sensor.""" - - _attr_translation_key = KEY_MOTD - - def __init__(self, server: MinecraftServer) -> None: - """Initialize MOTD sensor.""" - super().__init__( - server=server, - type_name=NAME_MOTD, - icon=ICON_MOTD, + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.unique_id}-{description.key}" + self._update_properties() + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._update_properties() + self.async_write_ha_state() + + @callback + def _update_properties(self) -> None: + """Update sensor properties.""" + self._attr_native_value = self.entity_description.value_fn( + self.coordinator.data ) - async def async_update(self) -> None: - """Update MOTD.""" - self._attr_native_value = self._server.data.motd + if func := self.entity_description.attributes_fn: + self._attr_extra_state_attributes = func(self.coordinator.data) diff --git a/homeassistant/components/minecraft_server/strings.json b/homeassistant/components/minecraft_server/strings.json index b4d68bc611744d..b64c96f580b331 100644 --- a/homeassistant/components/minecraft_server/strings.json +++ b/homeassistant/components/minecraft_server/strings.json @@ -12,11 +12,7 @@ }, "error": { "invalid_port": "Port must be in range from 1024 to 65535. Please correct it and try again.", - "cannot_connect": "Failed to connect to server. Please check the host and port and try again. Also ensure that you are running at least Minecraft version 1.7 on your server.", - "invalid_ip": "IP address is invalid (MAC address could not be determined). Please correct it and try again." - }, - "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + "cannot_connect": "Failed to connect to server. Please check the host and port and try again. Also ensure that you are running at least Minecraft version 1.7 on your server." } }, "entity": { diff --git a/homeassistant/components/mobile_app/entity.py b/homeassistant/components/mobile_app/entity.py index 3a2f038a0af007..120014d1d52a37 100644 --- a/homeassistant/components/mobile_app/entity.py +++ b/homeassistant/components/mobile_app/entity.py @@ -1,6 +1,8 @@ """A entity class for mobile_app.""" from __future__ import annotations +from typing import Any + from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ICON, CONF_NAME, CONF_UNIQUE_ID, STATE_UNAVAILABLE from homeassistant.core import callback @@ -36,7 +38,9 @@ async def async_added_to_hass(self): """Register callbacks.""" self.async_on_remove( async_dispatcher_connect( - self.hass, SIGNAL_SENSOR_UPDATE, self._handle_update + self.hass, + f"{SIGNAL_SENSOR_UPDATE}-{self._attr_unique_id}", + self._handle_update, ) ) @@ -96,10 +100,7 @@ def available(self) -> bool: return self._config.get(ATTR_SENSOR_STATE) != STATE_UNAVAILABLE @callback - def _handle_update(self, incoming_id, data): + def _handle_update(self, data: dict[str, Any]) -> None: """Handle async event updates.""" - if incoming_id != self._attr_unique_id: - return - - self._config = {**self._config, **data} + self._config.update(data) self.async_write_ha_state() diff --git a/homeassistant/components/mobile_app/helpers.py b/homeassistant/components/mobile_app/helpers.py index e8460b721a2704..e9bb3af51f280d 100644 --- a/homeassistant/components/mobile_app/helpers.py +++ b/homeassistant/components/mobile_app/helpers.py @@ -3,7 +3,6 @@ from collections.abc import Callable, Mapping from http import HTTPStatus -import json import logging from typing import Any @@ -14,7 +13,7 @@ from homeassistant.const import ATTR_DEVICE_ID, CONTENT_TYPE_JSON from homeassistant.core import Context, HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.json import JSONEncoder +from homeassistant.helpers.json import json_bytes from homeassistant.util.json import JsonValueType, json_loads from .const import ( @@ -182,7 +181,7 @@ def webhook_response( headers: Mapping[str, str] | None = None, ) -> Response: """Return a encrypted response if registration supports it.""" - data = json.dumps(data, cls=JSONEncoder) + json_data = json_bytes(data) if registration[ATTR_SUPPORTS_ENCRYPTION]: keylen, encrypt = setup_encrypt( @@ -190,17 +189,17 @@ def webhook_response( ) if ATTR_NO_LEGACY_ENCRYPTION in registration: - key = registration[CONF_SECRET] + key: bytes = registration[CONF_SECRET] else: key = registration[CONF_SECRET].encode("utf-8") key = key[:keylen] key = key.ljust(keylen, b"\0") - enc_data = encrypt(data.encode("utf-8"), key).decode("utf-8") - data = json.dumps({"encrypted": True, "encrypted_data": enc_data}) + enc_data = encrypt(json_data, key).decode("utf-8") + json_data = json_bytes({"encrypted": True, "encrypted_data": enc_data}) return Response( - text=data, status=status, content_type=CONTENT_TYPE_JSON, headers=headers + body=json_data, status=status, content_type=CONTENT_TYPE_JSON, headers=headers ) diff --git a/homeassistant/components/mobile_app/webhook.py b/homeassistant/components/mobile_app/webhook.py index 62417b0873a9a2..1a56b13ddc59d0 100644 --- a/homeassistant/components/mobile_app/webhook.py +++ b/homeassistant/components/mobile_app/webhook.py @@ -607,7 +607,7 @@ async def webhook_register_sensor( if changes: entity_registry.async_update_entity(existing_sensor, **changes) - async_dispatcher_send(hass, SIGNAL_SENSOR_UPDATE, unique_store_key, data) + async_dispatcher_send(hass, f"{SIGNAL_SENSOR_UPDATE}-{unique_store_key}", data) else: data[CONF_UNIQUE_ID] = unique_store_key data[ @@ -693,8 +693,7 @@ async def webhook_update_sensor_states( sensor[CONF_WEBHOOK_ID] = config_entry.data[CONF_WEBHOOK_ID] async_dispatcher_send( hass, - SIGNAL_SENSOR_UPDATE, - unique_store_key, + f"{SIGNAL_SENSOR_UPDATE}-{unique_store_key}", sensor, ) diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index cb36661d711169..85fba66b68a1c0 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -62,6 +62,7 @@ CONF_CLIMATES, CONF_CLOSE_COMM_ON_ERROR, CONF_DATA_TYPE, + CONF_DEVICE_ADDRESS, CONF_FANS, CONF_HVAC_MODE_AUTO, CONF_HVAC_MODE_COOL, @@ -105,6 +106,7 @@ CONF_TARGET_TEMP, CONF_TARGET_TEMP_WRITE_REGISTERS, CONF_VERIFY, + CONF_VIRTUAL_COUNT, CONF_WRITE_REGISTERS, CONF_WRITE_TYPE, CONF_ZERO_SUPPRESS, @@ -138,7 +140,8 @@ { vol.Required(CONF_NAME): cv.string, vol.Required(CONF_ADDRESS): cv.positive_int, - vol.Optional(CONF_SLAVE, default=0): cv.positive_int, + vol.Exclusive(CONF_DEVICE_ADDRESS, "slave_addr"): cv.positive_int, + vol.Exclusive(CONF_SLAVE, "slave_addr"): cv.positive_int, vol.Optional( CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL ): cv.positive_int, @@ -171,7 +174,6 @@ DataType.FLOAT32, DataType.FLOAT64, DataType.STRING, - DataType.STRING, DataType.CUSTOM, ] ), @@ -309,7 +311,8 @@ vol.Optional(CONF_DEVICE_CLASS): SENSOR_DEVICE_CLASSES_SCHEMA, vol.Optional(CONF_STATE_CLASS): SENSOR_STATE_CLASSES_SCHEMA, vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, - vol.Optional(CONF_SLAVE_COUNT, default=0): cv.positive_int, + vol.Exclusive(CONF_VIRTUAL_COUNT, "vir_sen_count"): cv.positive_int, + vol.Exclusive(CONF_SLAVE_COUNT, "vir_sen_count"): cv.positive_int, vol.Optional(CONF_MIN_VALUE): number_validator, vol.Optional(CONF_MAX_VALUE): number_validator, vol.Optional(CONF_NAN_VALUE): nan_validator, @@ -329,7 +332,8 @@ CALL_TYPE_REGISTER_INPUT, ] ), - vol.Optional(CONF_SLAVE_COUNT, default=0): cv.positive_int, + vol.Exclusive(CONF_VIRTUAL_COUNT, "vir_bin_count"): cv.positive_int, + vol.Exclusive(CONF_SLAVE_COUNT, "vir_bin_count"): cv.positive_int, } ) @@ -337,10 +341,10 @@ { vol.Optional(CONF_NAME, default=DEFAULT_HUB): cv.string, vol.Optional(CONF_TIMEOUT, default=3): cv.socket_timeout, - vol.Optional(CONF_CLOSE_COMM_ON_ERROR, default=True): cv.boolean, + vol.Optional(CONF_CLOSE_COMM_ON_ERROR): cv.boolean, vol.Optional(CONF_DELAY, default=0): cv.positive_int, vol.Optional(CONF_RETRIES, default=3): cv.positive_int, - vol.Optional(CONF_RETRY_ON_EMPTY, default=False): cv.boolean, + vol.Optional(CONF_RETRY_ON_EMPTY): cv.boolean, vol.Optional(CONF_MSG_WAIT): cv.positive_int, vol.Optional(CONF_BINARY_SENSORS): vol.All( cv.ensure_list, [BINARY_SENSOR_SCHEMA] diff --git a/homeassistant/components/modbus/base_platform.py b/homeassistant/components/modbus/base_platform.py index 7c3fcd78b05f9e..ee98b51b72ad21 100644 --- a/homeassistant/components/modbus/base_platform.py +++ b/homeassistant/components/modbus/base_platform.py @@ -21,6 +21,7 @@ CONF_SLAVE, CONF_STRUCTURE, CONF_UNIQUE_ID, + STATE_OFF, STATE_ON, ) from homeassistant.core import callback @@ -30,7 +31,6 @@ from homeassistant.helpers.restore_state import RestoreEntity from .const import ( - ACTIVE_SCAN_INTERVAL, CALL_TYPE_COIL, CALL_TYPE_DISCRETE, CALL_TYPE_REGISTER_HOLDING, @@ -42,6 +42,7 @@ CALL_TYPE_X_COILS, CALL_TYPE_X_REGISTER_HOLDINGS, CONF_DATA_TYPE, + CONF_DEVICE_ADDRESS, CONF_INPUT_TYPE, CONF_LAZY_ERROR, CONF_MAX_VALUE, @@ -58,6 +59,7 @@ CONF_SWAP_WORD, CONF_SWAP_WORD_BYTE, CONF_VERIFY, + CONF_VIRTUAL_COUNT, CONF_WRITE_TYPE, CONF_ZERO_SUPPRESS, SIGNAL_START_ENTITY, @@ -76,7 +78,7 @@ class BasePlatform(Entity): def __init__(self, hub: ModbusHub, entry: dict[str, Any]) -> None: """Initialize the Modbus binary sensor.""" self._hub = hub - self._slave = entry.get(CONF_SLAVE, 0) + self._slave = entry.get(CONF_SLAVE, None) or entry.get(CONF_DEVICE_ADDRESS, 0) self._address = int(entry[CONF_ADDRESS]) self._input_type = entry[CONF_INPUT_TYPE] self._value: str | None = None @@ -115,8 +117,9 @@ async def async_update(self, now: datetime | None = None) -> None: def async_run(self) -> None: """Remote start entity.""" self.async_hold(update=False) - if self._scan_interval == 0 or self._scan_interval > ACTIVE_SCAN_INTERVAL: - self._cancel_call = async_call_later(self.hass, 1, self.async_update) + self._cancel_call = async_call_later( + self.hass, timedelta(milliseconds=100), self.async_update + ) if self._scan_interval > 0: self._cancel_timer = async_track_time_interval( self.hass, self.async_update, timedelta(seconds=self._scan_interval) @@ -161,8 +164,12 @@ def __init__(self, hub: ModbusHub, config: dict) -> None: self._structure: str = config[CONF_STRUCTURE] self._precision = config[CONF_PRECISION] self._scale = config[CONF_SCALE] + if self._scale < 1 and not self._precision: + self._precision = 2 self._offset = config[CONF_OFFSET] - self._slave_count = config.get(CONF_SLAVE_COUNT, 0) + self._slave_count = config.get(CONF_SLAVE_COUNT, None) or config.get( + CONF_VIRTUAL_COUNT, 0 + ) self._slave_size = self._count = config[CONF_COUNT] def _swap_registers(self, registers: list[int], slave_count: int) -> list[int]: @@ -187,10 +194,14 @@ def _swap_registers(self, registers: list[int], slave_count: int) -> list[int]: registers.reverse() return registers - def __process_raw_value(self, entry: float | int | str) -> float | int | str | None: + def __process_raw_value( + self, entry: float | int | str | bytes + ) -> float | int | str | bytes | None: """Process value from sensor with NaN handling, scaling, offset, min/max etc.""" if self._nan_value and entry in (self._nan_value, -self._nan_value): return None + if isinstance(entry, bytes): + return entry val: float | int = self._scale * entry + self._offset if self._min_value is not None and val < self._min_value: return self._min_value @@ -231,14 +242,20 @@ def unpack_structure_result(self, registers: list[int]) -> str | None: if isinstance(v_temp, int) and self._precision == 0: v_result.append(str(v_temp)) elif v_temp is None: - v_result.append("") # pragma: no cover + v_result.append("0") elif v_temp != v_temp: # noqa: PLR0124 # NaN float detection replace with None - v_result.append("nan") # pragma: no cover + v_result.append("0") else: v_result.append(f"{float(v_temp):.{self._precision}f}") return ",".join(map(str, v_result)) + # NaN float detection replace with None + if val[0] != val[0]: # noqa: PLR0124 + return None + if byte_string == b"nan\x00": + return None + # Apply scale, precision, limits to floats and ints val_result = self.__process_raw_value(val[0]) @@ -248,15 +265,10 @@ def unpack_structure_result(self, registers: list[int]) -> str | None: if val_result is None: return None - # NaN float detection replace with None - if val_result != val_result: # noqa: PLR0124 - return None # pragma: no cover if isinstance(val_result, int) and self._precision == 0: return str(val_result) - if isinstance(val_result, str): - if val_result == "nan": - val_result = None # pragma: no cover - return val_result + if isinstance(val_result, bytes): + return val_result.decode() return f"{float(val_result):.{self._precision}f}" @@ -311,7 +323,10 @@ async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" await self.async_base_added_to_hass() if state := await self.async_get_last_state(): - self._attr_is_on = state.state == STATE_ON + if state.state == STATE_ON: + self._attr_is_on = True + elif state.state == STATE_OFF: + self._attr_is_on = False async def async_turn(self, command: int) -> None: """Evaluate switch result.""" diff --git a/homeassistant/components/modbus/binary_sensor.py b/homeassistant/components/modbus/binary_sensor.py index 05668bac0a9477..39174ae89311a1 100644 --- a/homeassistant/components/modbus/binary_sensor.py +++ b/homeassistant/components/modbus/binary_sensor.py @@ -24,7 +24,12 @@ from . import get_hub from .base_platform import BasePlatform -from .const import CALL_TYPE_COIL, CALL_TYPE_DISCRETE, CONF_SLAVE_COUNT +from .const import ( + CALL_TYPE_COIL, + CALL_TYPE_DISCRETE, + CONF_SLAVE_COUNT, + CONF_VIRTUAL_COUNT, +) from .modbus import ModbusHub _LOGGER = logging.getLogger(__name__) @@ -46,7 +51,9 @@ async def async_setup_platform( sensors: list[ModbusBinarySensor | SlaveSensor] = [] hub = get_hub(hass, discovery_info[CONF_NAME]) for entry in discovery_info[CONF_BINARY_SENSORS]: - slave_count = entry.get(CONF_SLAVE_COUNT, 0) + slave_count = entry.get(CONF_SLAVE_COUNT, None) or entry.get( + CONF_VIRTUAL_COUNT, 0 + ) sensor = ModbusBinarySensor(hub, entry, slave_count) if slave_count > 0: sensors.extend(await sensor.async_setup_slaves(hass, slave_count, entry)) @@ -115,10 +122,7 @@ async def async_update(self, now: datetime | None = None) -> None: self._result = result.bits else: self._result = result.registers - if len(self._result) >= 1: - self._attr_is_on = bool(self._result[0] & 1) - else: - self._attr_available = False + self._attr_is_on = bool(self._result[0] & 1) self.async_write_ha_state() if self._coordinator: diff --git a/homeassistant/components/modbus/climate.py b/homeassistant/components/modbus/climate.py index 3acf8d7ac296e2..df2983e9070f7a 100644 --- a/homeassistant/components/modbus/climate.py +++ b/homeassistant/components/modbus/climate.py @@ -247,10 +247,6 @@ async def async_update(self, now: datetime | None = None) -> None: # remark "now" is a dummy parameter to avoid problems with # async_track_time_interval - # do not allow multiple active calls to the same platform - if self._call_active: - return - self._call_active = True self._attr_target_temperature = await self._async_read_register( CALL_TYPE_REGISTER_HOLDING, self._target_temperature_register ) @@ -282,7 +278,6 @@ async def async_update(self, now: datetime | None = None) -> None: if onoff == 0: self._attr_hvac_mode = HVACMode.OFF - self._call_active = False self.async_write_ha_state() async def _async_read_register( diff --git a/homeassistant/components/modbus/const.py b/homeassistant/components/modbus/const.py index e509577267c3a4..92a38bb5e921c6 100644 --- a/homeassistant/components/modbus/const.py +++ b/homeassistant/components/modbus/const.py @@ -17,6 +17,7 @@ CONF_CLIMATES = "climates" CONF_CLOSE_COMM_ON_ERROR = "close_comm_on_error" CONF_DATA_TYPE = "data_type" +CONF_DEVICE_ADDRESS = "device_address" CONF_FANS = "fans" CONF_INPUT_TYPE = "input_type" CONF_LAZY_ERROR = "lazy_error_count" @@ -61,6 +62,7 @@ CONF_HVAC_MODE_FAN_ONLY = "state_fan_only" CONF_WRITE_REGISTERS = "write_registers" CONF_VERIFY = "verify" +CONF_VIRTUAL_COUNT = "virtual_count" CONF_WRITE_TYPE = "write_type" CONF_ZERO_SUPPRESS = "zero_suppress" diff --git a/homeassistant/components/modbus/cover.py b/homeassistant/components/modbus/cover.py index 3c4247c61fb1fe..27f9cb1fc1817a 100644 --- a/homeassistant/components/modbus/cover.py +++ b/homeassistant/components/modbus/cover.py @@ -138,14 +138,9 @@ async def async_update(self, now: datetime | None = None) -> None: """Update the state of the cover.""" # remark "now" is a dummy parameter to avoid problems with # async_track_time_interval - # do not allow multiple active calls to the same platform - if self._call_active: - return - self._call_active = True result = await self._hub.async_pb_call( self._slave, self._address, 1, self._input_type ) - self._call_active = False if result is None: if self._lazy_errors: self._lazy_errors -= 1 diff --git a/homeassistant/components/modbus/manifest.json b/homeassistant/components/modbus/manifest.json index a4187de77ebd15..7faf873b6551fc 100644 --- a/homeassistant/components/modbus/manifest.json +++ b/homeassistant/components/modbus/manifest.json @@ -1,10 +1,10 @@ { "domain": "modbus", "name": "Modbus", - "codeowners": ["@adamchengtkc", "@janiversen", "@vzahradnik"], + "codeowners": ["@janiversen"], "documentation": "https://www.home-assistant.io/integrations/modbus", "iot_class": "local_polling", "loggers": ["pymodbus"], "quality_scale": "gold", - "requirements": ["pymodbus==3.5.0"] + "requirements": ["pymodbus==3.5.2"] } diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index fdb7be3d3cf84c..4ef205aace3363 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -34,6 +34,7 @@ from homeassistant.helpers.discovery import async_load_platform 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.reload import async_setup_reload_service from homeassistant.helpers.typing import ConfigType @@ -167,11 +168,12 @@ async def async_stop_modbus(event: Event) -> None: async def async_write_register(service: ServiceCall) -> None: """Write Modbus registers.""" - unit = 0 + slave = 0 if ATTR_UNIT in service.data: - unit = int(float(service.data[ATTR_UNIT])) + slave = int(float(service.data[ATTR_UNIT])) + if ATTR_SLAVE in service.data: - unit = int(float(service.data[ATTR_SLAVE])) + slave = int(float(service.data[ATTR_SLAVE])) address = int(float(service.data[ATTR_ADDRESS])) value = service.data[ATTR_VALUE] hub = hub_collect[ @@ -179,29 +181,32 @@ async def async_write_register(service: ServiceCall) -> None: ] if isinstance(value, list): await hub.async_pb_call( - unit, address, [int(float(i)) for i in value], CALL_TYPE_WRITE_REGISTERS + slave, + address, + [int(float(i)) for i in value], + CALL_TYPE_WRITE_REGISTERS, ) else: await hub.async_pb_call( - unit, address, int(float(value)), CALL_TYPE_WRITE_REGISTER + slave, address, int(float(value)), CALL_TYPE_WRITE_REGISTER ) async def async_write_coil(service: ServiceCall) -> None: """Write Modbus coil.""" - unit = 0 + slave = 0 if ATTR_UNIT in service.data: - unit = int(float(service.data[ATTR_UNIT])) + slave = int(float(service.data[ATTR_UNIT])) if ATTR_SLAVE in service.data: - unit = int(float(service.data[ATTR_SLAVE])) + slave = int(float(service.data[ATTR_SLAVE])) address = service.data[ATTR_ADDRESS] state = service.data[ATTR_STATE] hub = hub_collect[ service.data[ATTR_HUB] if ATTR_HUB in service.data else DEFAULT_HUB ] if isinstance(state, list): - await hub.async_pb_call(unit, address, state, CALL_TYPE_WRITE_COILS) + await hub.async_pb_call(slave, address, state, CALL_TYPE_WRITE_COILS) else: - await hub.async_pb_call(unit, address, state, CALL_TYPE_WRITE_COIL) + await hub.async_pb_call(slave, address, state, CALL_TYPE_WRITE_COIL) for x_write in ( (SERVICE_WRITE_REGISTER, async_write_register, ATTR_VALUE, cv.positive_int), @@ -255,6 +260,42 @@ class ModbusHub: def __init__(self, hass: HomeAssistant, client_config: dict[str, Any]) -> None: """Initialize the Modbus hub.""" + if CONF_CLOSE_COMM_ON_ERROR in client_config: + async_create_issue( + hass, + DOMAIN, + "deprecated_close_comm_config", + breaks_in_ha_version="2024.4.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_close_comm_config", + translation_placeholders={ + "config_key": "close_comm_on_error", + "integration": DOMAIN, + "url": "https://www.home-assistant.io/integrations/modbus", + }, + ) + _LOGGER.warning( + "`close_comm_on_error`: is deprecated and will be removed in version 2024.4" + ) + if CONF_RETRY_ON_EMPTY in client_config: + async_create_issue( + hass, + DOMAIN, + "deprecated_retry_on_empty", + breaks_in_ha_version="2024.4.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_retry_on_empty", + translation_placeholders={ + "config_key": "retry_on_empty", + "integration": DOMAIN, + "url": "https://www.home-assistant.io/integrations/modbus", + }, + ) + _LOGGER.warning( + "`retry_on_empty`: is deprecated and will be removed in version 2024.4" + ) # generic configuration self._client: ModbusBaseClient | None = None self._async_cancel_listener: Callable[[], None] | None = None @@ -274,9 +315,8 @@ def __init__(self, hass: HomeAssistant, client_config: dict[str, Any]) -> None: self._pb_params = { "port": client_config[CONF_PORT], "timeout": client_config[CONF_TIMEOUT], - "reset_socket": client_config[CONF_CLOSE_COMM_ON_ERROR], "retries": client_config[CONF_RETRIES], - "retry_on_empty": client_config[CONF_RETRY_ON_EMPTY], + "retry_on_empty": True, } if self._config_type == SERIAL: # serial configuration @@ -387,19 +427,25 @@ def pb_connect(self) -> bool: return True def pb_call( - self, unit: int | None, address: int, value: int | list[int], use_call: str + self, slave: int | None, address: int, value: int | list[int], use_call: str ) -> ModbusResponse | None: """Call sync. pymodbus.""" - kwargs = {"slave": unit} if unit else {} + kwargs = {"slave": slave} if slave else {} entry = self._pb_request[use_call] try: result: ModbusResponse = entry.func(address, value, **kwargs) except ModbusException as exception_error: self._log_error(str(exception_error)) return None + if not result: + self._log_error("Error: pymodbus returned None") + return None if not hasattr(result, entry.attr): self._log_error(str(result)) return None + if result.isError(): # type: ignore[no-untyped-call] + self._log_error("Error: pymodbus returned isError True") + return None self._in_error = False return result diff --git a/homeassistant/components/modbus/sensor.py b/homeassistant/components/modbus/sensor.py index fe2d4bc415d88a..d7a6b4cca0f100 100644 --- a/homeassistant/components/modbus/sensor.py +++ b/homeassistant/components/modbus/sensor.py @@ -1,7 +1,7 @@ """Support for Modbus Register sensors.""" from __future__ import annotations -from datetime import datetime +from datetime import datetime, timedelta import logging from typing import Any @@ -19,6 +19,7 @@ ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.event import async_call_later from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, @@ -27,7 +28,7 @@ from . import get_hub from .base_platform import BaseStructPlatform -from .const import CONF_SLAVE_COUNT +from .const import CONF_SLAVE_COUNT, CONF_VIRTUAL_COUNT from .modbus import ModbusHub _LOGGER = logging.getLogger(__name__) @@ -49,7 +50,9 @@ async def async_setup_platform( sensors: list[ModbusRegisterSensor | SlaveSensor] = [] hub = get_hub(hass, discovery_info[CONF_NAME]) for entry in discovery_info[CONF_SENSORS]: - slave_count = entry.get(CONF_SLAVE_COUNT, 0) + slave_count = entry.get(CONF_SLAVE_COUNT, None) or entry.get( + CONF_VIRTUAL_COUNT, 0 + ) sensor = ModbusRegisterSensor(hub, entry, slave_count) if slave_count > 0: sensors.extend(await sensor.async_setup_slaves(hass, slave_count, entry)) @@ -106,12 +109,16 @@ async def async_update(self, now: datetime | None = None) -> None: """Update the state of the sensor.""" # remark "now" is a dummy parameter to avoid problems with # async_track_time_interval + self._cancel_call = None raw_result = await self._hub.async_pb_call( self._slave, self._address, self._count, self._input_type ) if raw_result is None: if self._lazy_errors: self._lazy_errors -= 1 + self._cancel_call = async_call_later( + self.hass, timedelta(seconds=1), self.async_update + ) return self._lazy_errors = self._lazy_error_count self._attr_available = False diff --git a/homeassistant/components/modbus/strings.json b/homeassistant/components/modbus/strings.json index 61694074d7920f..5f45d0df5963ba 100644 --- a/homeassistant/components/modbus/strings.json +++ b/homeassistant/components/modbus/strings.json @@ -68,5 +68,15 @@ } } } + }, + "issues": { + "deprecated_close_comm_config": { + "title": "`{config_key}` configuration key is being removed", + "description": "Please remove the `{config_key}` key from the {integration} entry in your configuration.yaml file and restart Home Assistant to fix this issue.\n\nCommunication is automatically closed on errors, see [the documentation]({url}) for other error handling parameters." + }, + "deprecated_retry_on_empty": { + "title": "`{config_key}` configuration key is being removed", + "description": "Please remove the `{config_key}` key from the {integration} entry in your configuration.yaml file and restart Home Assistant to fix this issue.\n\nRetry on empty is automatically applied, see [the documentation]({url}) for other error handling parameters." + } } } diff --git a/homeassistant/components/modbus/validators.py b/homeassistant/components/modbus/validators.py index f5f88ea5f59be7..ca08ace853ae48 100644 --- a/homeassistant/components/modbus/validators.py +++ b/homeassistant/components/modbus/validators.py @@ -25,11 +25,15 @@ from .const import ( CONF_DATA_TYPE, + CONF_DEVICE_ADDRESS, CONF_INPUT_TYPE, CONF_SLAVE_COUNT, CONF_SWAP, CONF_SWAP_BYTE, CONF_SWAP_NONE, + CONF_SWAP_WORD, + CONF_SWAP_WORD_BYTE, + CONF_VIRTUAL_COUNT, CONF_WRITE_TYPE, DEFAULT_HUB, DEFAULT_SCAN_INTERVAL, @@ -40,97 +44,112 @@ _LOGGER = logging.getLogger(__name__) -ENTRY = namedtuple("ENTRY", ["struct_id", "register_count"]) +ENTRY = namedtuple( + "ENTRY", + [ + "struct_id", + "register_count", + "validate_parm", + ], +) +PARM_IS_LEGAL = namedtuple( + "PARM_IS_LEGAL", + [ + "count", + "structure", + "slave_count", + "swap_byte", + "swap_word", + ], +) +# PARM_IS_LEGAL defines if the keywords: +# count: .. +# structure: .. +# swap: byte +# swap: word +# swap: word_byte (identical to swap: word) +# are legal to use. +# These keywords are only legal with some datatype: ... +# As expressed in DEFAULT_STRUCT_FORMAT + DEFAULT_STRUCT_FORMAT = { - DataType.INT8: ENTRY("b", 1), - DataType.INT16: ENTRY("h", 1), - DataType.INT32: ENTRY("i", 2), - DataType.INT64: ENTRY("q", 4), - DataType.UINT8: ENTRY("c", 1), - DataType.UINT16: ENTRY("H", 1), - DataType.UINT32: ENTRY("I", 2), - DataType.UINT64: ENTRY("Q", 4), - DataType.FLOAT16: ENTRY("e", 1), - DataType.FLOAT32: ENTRY("f", 2), - DataType.FLOAT64: ENTRY("d", 4), - DataType.STRING: ENTRY("s", 1), + DataType.INT8: ENTRY("b", 1, PARM_IS_LEGAL(False, False, False, False, False)), + DataType.UINT8: ENTRY("c", 1, PARM_IS_LEGAL(False, False, False, False, False)), + DataType.INT16: ENTRY("h", 1, PARM_IS_LEGAL(False, False, True, True, False)), + DataType.UINT16: ENTRY("H", 1, PARM_IS_LEGAL(False, False, True, True, False)), + DataType.FLOAT16: ENTRY("e", 1, PARM_IS_LEGAL(False, False, True, True, False)), + DataType.INT32: ENTRY("i", 2, PARM_IS_LEGAL(False, False, True, True, True)), + DataType.UINT32: ENTRY("I", 2, PARM_IS_LEGAL(False, False, True, True, True)), + DataType.FLOAT32: ENTRY("f", 2, PARM_IS_LEGAL(False, False, True, True, True)), + DataType.INT64: ENTRY("q", 4, PARM_IS_LEGAL(False, False, True, True, True)), + DataType.UINT64: ENTRY("Q", 4, PARM_IS_LEGAL(False, False, True, True, True)), + DataType.FLOAT64: ENTRY("d", 4, PARM_IS_LEGAL(False, False, True, True, True)), + DataType.STRING: ENTRY("s", 1, PARM_IS_LEGAL(True, False, False, False, False)), + DataType.CUSTOM: ENTRY("?", 0, PARM_IS_LEGAL(True, True, False, False, False)), } def struct_validator(config: dict[str, Any]) -> dict[str, Any]: """Sensor schema validator.""" - data_type = config[CONF_DATA_TYPE] - count = config.get(CONF_COUNT, 1) name = config[CONF_NAME] - structure = config.get(CONF_STRUCTURE) - slave_count = config.get(CONF_SLAVE_COUNT, 0) + 1 + data_type = config[CONF_DATA_TYPE] + if data_type == "int": + data_type = config[CONF_DATA_TYPE] = DataType.INT16 + count = config.get(CONF_COUNT, None) + structure = config.get(CONF_STRUCTURE, None) + slave_count = config.get(CONF_SLAVE_COUNT, None) + slave_name = CONF_SLAVE_COUNT + if not slave_count: + slave_count = config.get(CONF_VIRTUAL_COUNT, 0) + slave_name = CONF_VIRTUAL_COUNT swap_type = config.get(CONF_SWAP, CONF_SWAP_NONE) - if ( - slave_count > 1 - and count > 1 - and data_type not in (DataType.CUSTOM, DataType.STRING) - ): - error = f"{name} {CONF_COUNT} cannot be mixed with {data_type}" + validator = DEFAULT_STRUCT_FORMAT[data_type].validate_parm + if count and not validator.count: + error = f"{name}: `{CONF_COUNT}: {count}` cannot be combined with `{CONF_DATA_TYPE}: {data_type}`" raise vol.Invalid(error) - if config[CONF_DATA_TYPE] != DataType.CUSTOM: - if structure: - error = f"{name} structure: cannot be mixed with {data_type}" - - if config[CONF_DATA_TYPE] == DataType.CUSTOM: - if slave_count > 1: - error = f"{name}: `{CONF_STRUCTURE}` illegal with `{CONF_SLAVE_COUNT}` / `{CONF_SLAVE}`" - raise vol.Invalid(error) - if swap_type != CONF_SWAP_NONE: - error = f"{name}: `{CONF_STRUCTURE}` illegal with `{CONF_SWAP}`" - raise vol.Invalid(error) - if not structure: - error = ( - f"Error in sensor {name}. The `{CONF_STRUCTURE}` field cannot be empty" - ) + if not count and validator.count: + error = f"{name}: `{CONF_COUNT}:` missing, demanded with `{CONF_DATA_TYPE}: {data_type}`" + raise vol.Invalid(error) + if structure and not validator.structure: + error = f"{name}: `{CONF_STRUCTURE}: {structure}` cannot be combined with `{CONF_DATA_TYPE}: {data_type}`" + raise vol.Invalid(error) + if not structure and validator.structure: + error = f"{name}: `{CONF_STRUCTURE}` missing or empty, demanded with `{CONF_DATA_TYPE}: {data_type}`" + raise vol.Invalid(error) + if slave_count and not validator.slave_count: + error = f"{name}: `{slave_name}: {slave_count}` cannot be combined with `{CONF_DATA_TYPE}: {data_type}`" + raise vol.Invalid(error) + if swap_type != CONF_SWAP_NONE: + swap_type_validator = { + CONF_SWAP_NONE: False, + CONF_SWAP_BYTE: validator.swap_byte, + CONF_SWAP_WORD: validator.swap_word, + CONF_SWAP_WORD_BYTE: validator.swap_word, + }[swap_type] + if not swap_type_validator: + error = f"{name}: `{CONF_SWAP}:{swap_type}` cannot be combined with `{CONF_DATA_TYPE}: {data_type}`" raise vol.Invalid(error) + if config[CONF_DATA_TYPE] == DataType.CUSTOM: try: size = struct.calcsize(structure) except struct.error as err: - raise vol.Invalid(f"Error in {name} structure: {str(err)}") from err - - count = config.get(CONF_COUNT, 1) + raise vol.Invalid( + f"{name}: error in structure format --> {str(err)}" + ) from err bytecount = count * 2 if bytecount != size: raise vol.Invalid( - f"Structure request {size} bytes, " - f"but {count} registers have a size of {bytecount} bytes" + f"{name}: Size of structure is {size} bytes but `{CONF_COUNT}: {count}` is {bytecount} bytes" ) - return { - **config, - CONF_STRUCTURE: structure, - CONF_SWAP: swap_type, - } - if data_type not in DEFAULT_STRUCT_FORMAT: - error = f"Error in sensor {name}. data_type `{data_type}` not supported" - raise vol.Invalid(error) - if slave_count > 1 and data_type == DataType.STRING: - error = f"{name}: `{data_type}` illegal with `{CONF_SLAVE_COUNT}`" - raise vol.Invalid(error) - - if CONF_COUNT not in config: + else: config[CONF_COUNT] = DEFAULT_STRUCT_FORMAT[data_type].register_count - if swap_type != CONF_SWAP_NONE: - if swap_type == CONF_SWAP_BYTE: - regs_needed = 1 - else: # CONF_SWAP_WORD_BYTE, CONF_SWAP_WORD - regs_needed = 2 - count = config[CONF_COUNT] - if count < regs_needed or (count % regs_needed) != 0: - raise vol.Invalid( - f"Error in sensor {name} swap({swap_type}) " - f"impossible because datatype({data_type}) is too small" + if slave_count: + structure = ( + f">{slave_count + 1}{DEFAULT_STRUCT_FORMAT[data_type].struct_id}" ) - structure = f">{DEFAULT_STRUCT_FORMAT[data_type].struct_id}" - if slave_count > 1: - structure = f">{slave_count}{DEFAULT_STRUCT_FORMAT[data_type].struct_id}" - else: - structure = f">{DEFAULT_STRUCT_FORMAT[data_type].struct_id}" + else: + structure = f">{DEFAULT_STRUCT_FORMAT[data_type].struct_id}" return { **config, CONF_STRUCTURE: structure, @@ -228,7 +247,8 @@ def duplicate_entity_validator(config: dict) -> dict: addr += "_" + str(entry[CONF_COMMAND_ON]) if CONF_COMMAND_OFF in entry: addr += "_" + str(entry[CONF_COMMAND_OFF]) - addr += "_" + str(entry.get(CONF_SLAVE, 0)) + inx = entry.get(CONF_SLAVE, None) or entry.get(CONF_DEVICE_ADDRESS, 0) + addr += "_" + str(inx) if addr in addresses: err = ( f"Modbus {component}/{name} address {addr} is duplicate, second" diff --git a/homeassistant/components/motion_blinds/__init__.py b/homeassistant/components/motion_blinds/__init__.py index 9ea0f6ddbc9ec7..188f3a784acba1 100644 --- a/homeassistant/components/motion_blinds/__init__.py +++ b/homeassistant/components/motion_blinds/__init__.py @@ -5,13 +5,12 @@ from socket import timeout from typing import TYPE_CHECKING, Any -from motionblinds import DEVICE_TYPES_WIFI, AsyncMotionMulticast, ParseException +from motionblinds import AsyncMotionMulticast, ParseException from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import CONF_API_KEY, CONF_HOST, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import ( @@ -27,8 +26,6 @@ KEY_MULTICAST_LISTENER, KEY_SETUP_LOCK, KEY_UNSUB_STOP, - KEY_VERSION, - MANUFACTURER, PLATFORMS, UPDATE_INTERVAL, UPDATE_INTERVAL_FAST, @@ -183,32 +180,14 @@ def stop_motion_multicast(event): # Fetch initial data so we have data when entities subscribe await coordinator.async_config_entry_first_refresh() - if motion_gateway.firmware is not None: - version = f"{motion_gateway.firmware}, protocol: {motion_gateway.protocol}" - else: - version = f"Protocol: {motion_gateway.protocol}" - hass.data[DOMAIN][entry.entry_id] = { KEY_GATEWAY: motion_gateway, KEY_COORDINATOR: coordinator, - KEY_VERSION: version, } if TYPE_CHECKING: assert entry.unique_id is not None - if motion_gateway.device_type not in DEVICE_TYPES_WIFI: - device_registry = dr.async_get(hass) - device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, motion_gateway.mac)}, - identifiers={(DOMAIN, motion_gateway.mac)}, - manufacturer=MANUFACTURER, - name=entry.title, - model="Wi-Fi bridge", - sw_version=version, - ) - 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/motion_blinds/const.py b/homeassistant/components/motion_blinds/const.py index d241f03a02e913..429259a91c1bc9 100644 --- a/homeassistant/components/motion_blinds/const.py +++ b/homeassistant/components/motion_blinds/const.py @@ -18,7 +18,6 @@ KEY_MULTICAST_LISTENER = "multicast_listener" KEY_SETUP_LOCK = "setup_lock" KEY_UNSUB_STOP = "unsub_stop" -KEY_VERSION = "version" ATTR_WIDTH = "width" ATTR_ABSOLUTE_POSITION = "absolute_position" diff --git a/homeassistant/components/motion_blinds/cover.py b/homeassistant/components/motion_blinds/cover.py index c9578380048432..833d26402025c1 100644 --- a/homeassistant/components/motion_blinds/cover.py +++ b/homeassistant/components/motion_blinds/cover.py @@ -16,15 +16,9 @@ ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, HomeAssistant -from homeassistant.helpers import ( - config_validation as cv, - device_registry as dr, - entity_platform, -) -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_call_later -from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( ATTR_ABSOLUTE_POSITION, @@ -33,14 +27,12 @@ DOMAIN, KEY_COORDINATOR, KEY_GATEWAY, - KEY_VERSION, - MANUFACTURER, SERVICE_SET_ABSOLUTE_POSITION, UPDATE_DELAY_STOP, UPDATE_INTERVAL_MOVING, UPDATE_INTERVAL_MOVING_WIFI, ) -from .gateway import device_name +from .entity import MotionCoordinatorEntity _LOGGER = logging.getLogger(__name__) @@ -96,7 +88,6 @@ async def async_setup_entry( entities = [] motion_gateway = hass.data[DOMAIN][config_entry.entry_id][KEY_GATEWAY] coordinator = hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR] - sw_version = hass.data[DOMAIN][config_entry.entry_id][KEY_VERSION] for blind in motion_gateway.device_list.values(): if blind.type in POSITION_DEVICE_MAP: @@ -105,7 +96,6 @@ async def async_setup_entry( coordinator, blind, POSITION_DEVICE_MAP[blind.type], - sw_version, ) ) @@ -115,7 +105,6 @@ async def async_setup_entry( coordinator, blind, TILT_DEVICE_MAP[blind.type], - sw_version, ) ) @@ -125,7 +114,6 @@ async def async_setup_entry( coordinator, blind, TILT_ONLY_DEVICE_MAP[blind.type], - sw_version, ) ) @@ -135,7 +123,6 @@ async def async_setup_entry( coordinator, blind, TDBU_DEVICE_MAP[blind.type], - sw_version, "Top", ) ) @@ -144,7 +131,6 @@ async def async_setup_entry( coordinator, blind, TDBU_DEVICE_MAP[blind.type], - sw_version, "Bottom", ) ) @@ -153,7 +139,6 @@ async def async_setup_entry( coordinator, blind, TDBU_DEVICE_MAP[blind.type], - sw_version, "Combined", ) ) @@ -168,7 +153,6 @@ async def async_setup_entry( coordinator, blind, POSITION_DEVICE_MAP[BlindType.RollerBlind], - sw_version, ) ) @@ -182,44 +166,26 @@ async def async_setup_entry( ) -class MotionPositionDevice(CoordinatorEntity, CoverEntity): +class MotionPositionDevice(MotionCoordinatorEntity, CoverEntity): """Representation of a Motion Blind Device.""" + _attr_name = None _restore_tilt = False - def __init__(self, coordinator, blind, device_class, sw_version): + def __init__(self, coordinator, blind, device_class): """Initialize the blind.""" - super().__init__(coordinator) + super().__init__(coordinator, blind) - self._blind = blind - self._api_lock = coordinator.api_lock self._requesting_position: CALLBACK_TYPE | None = None self._previous_positions = [] if blind.device_type in DEVICE_TYPES_WIFI: self._update_interval_moving = UPDATE_INTERVAL_MOVING_WIFI - via_device = () - connections = {(dr.CONNECTION_NETWORK_MAC, blind.mac)} else: self._update_interval_moving = UPDATE_INTERVAL_MOVING - via_device = (DOMAIN, blind._gateway.mac) - connections = {} - sw_version = None - name = device_name(blind) self._attr_device_class = device_class - self._attr_name = name self._attr_unique_id = blind.mac - self._attr_device_info = DeviceInfo( - connections=connections, - identifiers={(DOMAIN, blind.mac)}, - manufacturer=MANUFACTURER, - model=blind.blind_type, - name=name, - via_device=via_device, - sw_version=sw_version, - hw_version=blind.wireless_name, - ) @property def available(self) -> bool: @@ -249,16 +215,6 @@ def is_closed(self) -> bool | None: return None return self._blind.position == 100 - async def async_added_to_hass(self) -> None: - """Subscribe to multicast pushes and register signal handler.""" - self._blind.Register_callback(self.unique_id, self.schedule_update_ha_state) - await super().async_added_to_hass() - - async def async_will_remove_from_hass(self) -> None: - """Unsubscribe when removed.""" - self._blind.Remove_callback(self.unique_id) - await super().async_will_remove_from_hass() - async def async_scheduled_update_request(self, *_): """Request a state update from the blind at a scheduled point in time.""" # add the last position to the list and keep the list at max 2 items @@ -439,12 +395,12 @@ async def async_set_absolute_position(self, **kwargs): class MotionTDBUDevice(MotionPositionDevice): """Representation of a Motion Top Down Bottom Up blind Device.""" - def __init__(self, coordinator, blind, device_class, sw_version, motor): + def __init__(self, coordinator, blind, device_class, motor): """Initialize the blind.""" - super().__init__(coordinator, blind, device_class, sw_version) + super().__init__(coordinator, blind, device_class) self._motor = motor self._motor_key = motor[0] - self._attr_name = f"{device_name(blind)} {motor}" + self._attr_translation_key = motor.lower() self._attr_unique_id = f"{blind.mac}-{motor}" if self._motor not in ["Bottom", "Top", "Combined"]: diff --git a/homeassistant/components/motion_blinds/entity.py b/homeassistant/components/motion_blinds/entity.py new file mode 100644 index 00000000000000..8f3ac05228dae4 --- /dev/null +++ b/homeassistant/components/motion_blinds/entity.py @@ -0,0 +1,96 @@ +"""Support for Motion Blinds using their WLAN API.""" +from __future__ import annotations + +from motionblinds import DEVICE_TYPES_GATEWAY, DEVICE_TYPES_WIFI, MotionGateway +from motionblinds.motion_blinds import MotionBlind + +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import DataUpdateCoordinatorMotionBlinds +from .const import ( + ATTR_AVAILABLE, + DEFAULT_GATEWAY_NAME, + DOMAIN, + KEY_GATEWAY, + MANUFACTURER, +) +from .gateway import device_name + + +class MotionCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinatorMotionBlinds]): + """Representation of a Motion Blind entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: DataUpdateCoordinatorMotionBlinds, + blind: MotionGateway | MotionBlind, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + + self._blind = blind + self._api_lock = coordinator.api_lock + + if blind.device_type in DEVICE_TYPES_GATEWAY: + gateway = blind + else: + gateway = blind._gateway + if gateway.firmware is not None: + sw_version = f"{gateway.firmware}, protocol: {gateway.protocol}" + else: + sw_version = f"Protocol: {gateway.protocol}" + + if blind.device_type in DEVICE_TYPES_GATEWAY: + self._attr_device_info = DeviceInfo( + connections={(dr.CONNECTION_NETWORK_MAC, blind.mac)}, + identifiers={(DOMAIN, blind.mac)}, + manufacturer=MANUFACTURER, + name=DEFAULT_GATEWAY_NAME, + model="Wi-Fi bridge", + sw_version=sw_version, + ) + elif blind.device_type in DEVICE_TYPES_WIFI: + self._attr_device_info = DeviceInfo( + connections={(dr.CONNECTION_NETWORK_MAC, blind.mac)}, + identifiers={(DOMAIN, blind.mac)}, + manufacturer=MANUFACTURER, + model=blind.blind_type, + name=device_name(blind), + sw_version=sw_version, + hw_version=blind.wireless_name, + ) + else: + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, blind.mac)}, + manufacturer=MANUFACTURER, + model=blind.blind_type, + name=device_name(blind), + via_device=(DOMAIN, blind._gateway.mac), + hw_version=blind.wireless_name, + ) + + @property + def available(self) -> bool: + """Return True if entity is available.""" + if self.coordinator.data is None: + return False + + gateway_available = self.coordinator.data[KEY_GATEWAY][ATTR_AVAILABLE] + if not gateway_available or self._blind.device_type in DEVICE_TYPES_GATEWAY: + return gateway_available + + return self.coordinator.data[self._blind.mac][ATTR_AVAILABLE] + + async def async_added_to_hass(self) -> None: + """Subscribe to multicast pushes and register signal handler.""" + self._blind.Register_callback(self.unique_id, self.schedule_update_ha_state) + await super().async_added_to_hass() + + async def async_will_remove_from_hass(self) -> None: + """Unsubscribe when removed.""" + self._blind.Remove_callback(self.unique_id) + await super().async_will_remove_from_hass() diff --git a/homeassistant/components/motion_blinds/sensor.py b/homeassistant/components/motion_blinds/sensor.py index bca1c1ef1dd33a..d8dc25e000666a 100644 --- a/homeassistant/components/motion_blinds/sensor.py +++ b/homeassistant/components/motion_blinds/sensor.py @@ -9,16 +9,12 @@ EntityCategory, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import ATTR_AVAILABLE, DOMAIN, KEY_COORDINATOR, KEY_GATEWAY -from .gateway import device_name +from .const import DOMAIN, KEY_COORDINATOR, KEY_GATEWAY +from .entity import MotionCoordinatorEntity ATTR_BATTERY_VOLTAGE = "battery_voltage" -TYPE_BLIND = "blind" -TYPE_GATEWAY = "gateway" async def async_setup_entry( @@ -32,7 +28,7 @@ async def async_setup_entry( coordinator = hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR] for blind in motion_gateway.device_list.values(): - entities.append(MotionSignalStrengthSensor(coordinator, blind, TYPE_BLIND)) + entities.append(MotionSignalStrengthSensor(coordinator, blind)) if blind.type == BlindType.TopDownBottomUp: entities.append(MotionTDBUBatterySensor(coordinator, blind, "Bottom")) entities.append(MotionTDBUBatterySensor(coordinator, blind, "Top")) @@ -42,14 +38,12 @@ async def async_setup_entry( # Do not add signal sensor twice for direct WiFi blinds if motion_gateway.device_type not in DEVICE_TYPES_WIFI: - entities.append( - MotionSignalStrengthSensor(coordinator, motion_gateway, TYPE_GATEWAY) - ) + entities.append(MotionSignalStrengthSensor(coordinator, motion_gateway)) async_add_entities(entities) -class MotionBatterySensor(CoordinatorEntity, SensorEntity): +class MotionBatterySensor(MotionCoordinatorEntity, SensorEntity): """Representation of a Motion Battery Sensor.""" _attr_device_class = SensorDeviceClass.BATTERY @@ -57,24 +51,9 @@ class MotionBatterySensor(CoordinatorEntity, SensorEntity): def __init__(self, coordinator, blind): """Initialize the Motion Battery Sensor.""" - super().__init__(coordinator) - - self._blind = blind - self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, blind.mac)}) - self._attr_name = f"{device_name(blind)} battery" + super().__init__(coordinator, blind) self._attr_unique_id = f"{blind.mac}-battery" - @property - def available(self) -> bool: - """Return True if entity is available.""" - if self.coordinator.data is None: - return False - - if not self.coordinator.data[KEY_GATEWAY][ATTR_AVAILABLE]: - return False - - return self.coordinator.data[self._blind.mac][ATTR_AVAILABLE] - @property def native_value(self): """Return the state of the sensor.""" @@ -85,16 +64,6 @@ def extra_state_attributes(self): """Return device specific state attributes.""" return {ATTR_BATTERY_VOLTAGE: self._blind.battery_voltage} - async def async_added_to_hass(self) -> None: - """Subscribe to multicast pushes.""" - self._blind.Register_callback(self.unique_id, self.schedule_update_ha_state) - await super().async_added_to_hass() - - async def async_will_remove_from_hass(self) -> None: - """Unsubscribe when removed.""" - self._blind.Remove_callback(self.unique_id) - await super().async_will_remove_from_hass() - class MotionTDBUBatterySensor(MotionBatterySensor): """Representation of a Motion Battery Sensor for a Top Down Bottom Up blind.""" @@ -105,7 +74,7 @@ def __init__(self, coordinator, blind, motor): self._motor = motor self._attr_unique_id = f"{blind.mac}-{motor}-battery" - self._attr_name = f"{device_name(blind)} {motor} battery" + self._attr_translation_key = f"{motor.lower()}_battery" @property def native_value(self): @@ -125,7 +94,7 @@ def extra_state_attributes(self): return attributes -class MotionSignalStrengthSensor(CoordinatorEntity, SensorEntity): +class MotionSignalStrengthSensor(MotionCoordinatorEntity, SensorEntity): """Representation of a Motion Signal Strength Sensor.""" _attr_device_class = SensorDeviceClass.SIGNAL_STRENGTH @@ -133,47 +102,12 @@ class MotionSignalStrengthSensor(CoordinatorEntity, SensorEntity): _attr_native_unit_of_measurement = SIGNAL_STRENGTH_DECIBELS_MILLIWATT _attr_entity_category = EntityCategory.DIAGNOSTIC - def __init__(self, coordinator, device, device_type): + def __init__(self, coordinator, blind): """Initialize the Motion Signal Strength Sensor.""" - super().__init__(coordinator) - - if device_type == TYPE_GATEWAY: - name = "Motion gateway signal strength" - else: - name = f"{device_name(device)} signal strength" - - self._device = device - self._device_type = device_type - self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, device.mac)}) - self._attr_unique_id = f"{device.mac}-RSSI" - self._attr_name = name - - @property - def available(self) -> bool: - """Return True if entity is available.""" - if self.coordinator.data is None: - return False - - gateway_available = self.coordinator.data[KEY_GATEWAY][ATTR_AVAILABLE] - if self._device_type == TYPE_GATEWAY: - return gateway_available - - return ( - gateway_available - and self.coordinator.data[self._device.mac][ATTR_AVAILABLE] - ) + super().__init__(coordinator, blind) + self._attr_unique_id = f"{blind.mac}-RSSI" @property def native_value(self): """Return the state of the sensor.""" - return self._device.RSSI - - async def async_added_to_hass(self) -> None: - """Subscribe to multicast pushes.""" - self._device.Register_callback(self.unique_id, self.schedule_update_ha_state) - await super().async_added_to_hass() - - async def async_will_remove_from_hass(self) -> None: - """Unsubscribe when removed.""" - self._device.Remove_callback(self.unique_id) - await super().async_will_remove_from_hass() + return self._blind.RSSI diff --git a/homeassistant/components/motion_blinds/strings.json b/homeassistant/components/motion_blinds/strings.json index 0e0a32bfb2420e..cb9468c3a27d5c 100644 --- a/homeassistant/components/motion_blinds/strings.json +++ b/homeassistant/components/motion_blinds/strings.json @@ -60,5 +60,26 @@ } } } + }, + "entity": { + "cover": { + "top": { + "name": "Top" + }, + "bottom": { + "name": "Bottom" + }, + "combined": { + "name": "Combined" + } + }, + "sensor": { + "top_battery": { + "name": "Top battery" + }, + "bottom_battery": { + "name": "Bottom battery" + } + } } } diff --git a/homeassistant/components/motioneye/camera.py b/homeassistant/components/motioneye/camera.py index 683308e081cedb..fd3f0ec86c0298 100644 --- a/homeassistant/components/motioneye/camera.py +++ b/homeassistant/components/motioneye/camera.py @@ -143,6 +143,10 @@ def camera_add(camera: dict[str, Any]) -> None: class MotionEyeMjpegCamera(MotionEyeEntity, MjpegCamera): """motionEye mjpeg camera.""" + _attr_brand = MOTIONEYE_MANUFACTURER + # motionEye cameras are always streaming or unavailable. + _attr_is_streaming = True + def __init__( self, config_entry_id: str, @@ -158,9 +162,6 @@ def __init__( self._surveillance_password = password self._motion_detection_enabled: bool = camera.get(KEY_MOTION_DETECTION, False) - # motionEye cameras are always streaming or unavailable. - self._attr_is_streaming = True - MotionEyeEntity.__init__( self, config_entry_id, @@ -249,11 +250,6 @@ def _handle_coordinator_update(self) -> None: ) super()._handle_coordinator_update() - @property - def brand(self) -> str: - """Return the camera brand.""" - return MOTIONEYE_MANUFACTURER - @property def motion_detection_enabled(self) -> bool: """Return the camera motion detection status.""" diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 9ec6447b32cd1d..5b5c39e6831c7f 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -5,7 +5,7 @@ from collections.abc import Callable from datetime import datetime import logging -from typing import Any, TypeVar, cast +from typing import TYPE_CHECKING, Any, TypeVar, cast import jinja2 import voluptuous as vol @@ -248,7 +248,7 @@ async def _setup_client() -> tuple[MqttData, dict[str, Any]]: client_available: asyncio.Future[bool] if DATA_MQTT_AVAILABLE not in hass.data: - client_available = hass.data[DATA_MQTT_AVAILABLE] = asyncio.Future() + client_available = hass.data[DATA_MQTT_AVAILABLE] = hass.loop.create_future() else: client_available = hass.data[DATA_MQTT_AVAILABLE] @@ -313,7 +313,8 @@ async def async_publish_service(call: ServiceCall) -> None: ) return - assert msg_topic is not None + if TYPE_CHECKING: + assert msg_topic is not None await mqtt_data.client.async_publish(msg_topic, payload, qos, retain) hass.services.async_register( diff --git a/homeassistant/components/mqtt/alarm_control_panel.py b/homeassistant/components/mqtt/alarm_control_panel.py index a0939fdc615647..4639bd82eb3a03 100644 --- a/homeassistant/components/mqtt/alarm_control_panel.py +++ b/homeassistant/components/mqtt/alarm_control_panel.py @@ -161,8 +161,6 @@ def __init__( discovery_data: DiscoveryInfoType | None, ) -> None: """Init the MQTT Alarm Control Panel.""" - self._state: str | None = None - MqttEntity.__init__(self, hass, config, config_entry, discovery_data) @staticmethod @@ -183,6 +181,16 @@ def _setup_from_config(self, config: ConfigType) -> None: for feature in self._config[CONF_SUPPORTED_FEATURES]: self._attr_supported_features |= _SUPPORTED_FEATURES[feature] + if (code := self._config.get(CONF_CODE)) is None: + self._attr_code_format = None + elif code == REMOTE_CODE or ( + isinstance(code, str) and re.search("^\\d+$", code) + ): + self._attr_code_format = alarm.CodeFormat.NUMBER + else: + self._attr_code_format = alarm.CodeFormat.TEXT + self._attr_code_arm_required = bool(self._config[CONF_CODE_ARM_REQUIRED]) + def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" @@ -205,7 +213,7 @@ def message_received(msg: ReceiveMessage) -> None: ): _LOGGER.warning("Received unexpected payload: %s", msg.payload) return - self._state = str(payload) + self._attr_state = str(payload) get_mqtt_data(self.hass).state_write_requests.write_state_request(self) self._sub_state = subscription.async_prepare_subscribe_topics( @@ -225,26 +233,6 @@ async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" await subscription.async_subscribe_topics(self.hass, self._sub_state) - @property - def state(self) -> str | None: - """Return the state of the device.""" - return self._state - - @property - def code_format(self) -> alarm.CodeFormat | None: - """Return one or more digits/characters.""" - code: str | None - if (code := self._config.get(CONF_CODE)) is None: - return None - if code == REMOTE_CODE or (isinstance(code, str) and re.search("^\\d+$", code)): - return alarm.CodeFormat.NUMBER - return alarm.CodeFormat.TEXT - - @property - def code_arm_required(self) -> bool: - """Whether the code is required for arm actions.""" - return bool(self._config[CONF_CODE_ARM_REQUIRED]) - async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command. diff --git a/homeassistant/components/mqtt/binary_sensor.py b/homeassistant/components/mqtt/binary_sensor.py index 0d4b2c4a7b4572..505305cad3e39c 100644 --- a/homeassistant/components/mqtt/binary_sensor.py +++ b/homeassistant/components/mqtt/binary_sensor.py @@ -29,7 +29,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.helpers.event as evt -from homeassistant.helpers.event import async_track_point_in_utc_time +from homeassistant.helpers.event import async_call_later from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util @@ -43,9 +43,9 @@ MqttAvailability, MqttEntity, async_setup_entry_helper, + write_state_on_attr_change, ) from .models import MqttValueTemplate, ReceiveMessage -from .util import get_mqtt_data _LOGGER = logging.getLogger(__name__) @@ -128,15 +128,17 @@ async def mqtt_async_added_to_hass(self) -> None: expiration_at: datetime = last_state.last_changed + timedelta( seconds=self._expire_after ) - if expiration_at < (time_now := dt_util.utcnow()): + remain_seconds = (expiration_at - dt_util.utcnow()).total_seconds() + + if remain_seconds <= 0: # Skip reactivating the binary_sensor _LOGGER.debug("Skip state recovery after reload for %s", self.entity_id) return self._expired = False self._attr_is_on = last_state.state == STATE_ON - self._expiration_trigger = async_track_point_in_utc_time( - self.hass, self._value_is_expired, expiration_at + self._expiration_trigger = async_call_later( + self.hass, remain_seconds, self._value_is_expired ) _LOGGER.debug( ( @@ -144,7 +146,7 @@ async def mqtt_async_added_to_hass(self) -> None: " expiring %s" ), self.entity_id, - expiration_at - time_now, + remain_seconds, ) async def async_will_remove_from_hass(self) -> None: @@ -189,6 +191,7 @@ def off_delay_listener(now: datetime) -> None: @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_is_on"}) def state_message_received(msg: ReceiveMessage) -> None: """Handle a new received MQTT state message.""" # auto-expire enabled? @@ -202,10 +205,8 @@ def state_message_received(msg: ReceiveMessage) -> None: self._expiration_trigger() # Set new trigger - expiration_at = dt_util.utcnow() + timedelta(seconds=self._expire_after) - - self._expiration_trigger = async_track_point_in_utc_time( - self.hass, self._value_is_expired, expiration_at + self._expiration_trigger = async_call_later( + self.hass, self._expire_after, self._value_is_expired ) payload = self._value_template(msg.payload) @@ -215,7 +216,7 @@ def state_message_received(msg: ReceiveMessage) -> None: "Empty template output for entity: %s with state topic: %s." " Payload: '%s', with value template '%s'" ), - self._config[CONF_NAME], + self.entity_id, self._config[CONF_STATE_TOPIC], msg.payload, self._config.get(CONF_VALUE_TEMPLATE), @@ -240,7 +241,7 @@ def state_message_received(msg: ReceiveMessage) -> None: "No matching payload found for entity: %s with state topic: %s." " Payload: '%s'%s" ), - self._config[CONF_NAME], + self.entity_id, self._config[CONF_STATE_TOPIC], msg.payload, template_info, @@ -257,8 +258,6 @@ def state_message_received(msg: ReceiveMessage) -> None: self.hass, off_delay, off_delay_listener ) - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) - self._sub_state = subscription.async_prepare_subscribe_topics( self.hass, self._sub_state, diff --git a/homeassistant/components/mqtt/camera.py b/homeassistant/components/mqtt/camera.py index 166bfdd38ccdc8..edddd0f2239be7 100644 --- a/homeassistant/components/mqtt/camera.py +++ b/homeassistant/components/mqtt/camera.py @@ -4,6 +4,7 @@ from base64 import b64decode import functools import logging +from typing import TYPE_CHECKING import voluptuous as vol @@ -112,7 +113,8 @@ def message_received(msg: ReceiveMessage) -> None: if CONF_IMAGE_ENCODING in self._config: self._last_image = b64decode(msg.payload) else: - assert isinstance(msg.payload, bytes) + if TYPE_CHECKING: + assert isinstance(msg.payload, bytes) self._last_image = msg.payload self._sub_state = subscription.async_prepare_subscribe_topics( diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index 62f1f55401d0b8..733645c4788b41 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -110,7 +110,7 @@ def publish( encoding: str | None = DEFAULT_ENCODING, ) -> None: """Publish message to a MQTT topic.""" - hass.add_job(async_publish, hass, topic, payload, qos, retain, encoding) + hass.create_task(async_publish(hass, topic, payload, qos, retain, encoding)) async def async_publish( @@ -376,6 +376,7 @@ def __init__( ) -> None: """Initialize Home Assistant MQTT client.""" self.hass = hass + self.loop = hass.loop self.config_entry = config_entry self.conf = conf @@ -806,7 +807,7 @@ def _mqtt_on_message( self, _mqttc: mqtt.Client, _userdata: None, msg: mqtt.MQTTMessage ) -> None: """Message received callback.""" - self.hass.add_job(self._mqtt_handle_message, msg) + self.loop.call_soon_threadsafe(self._mqtt_handle_message, msg) @lru_cache(None) # pylint: disable=method-cache-max-size-none def _matching_subscriptions(self, topic: str) -> list[Subscription]: diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index 9f960b0d9099eb..4f46dffec11b2d 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -6,7 +6,7 @@ import queue from ssl import PROTOCOL_TLS_CLIENT, SSLContext, SSLError from types import MappingProxyType -from typing import Any +from typing import TYPE_CHECKING, Any from cryptography.hazmat.primitives.serialization import load_pem_private_key from cryptography.x509 import load_pem_x509_certificate @@ -224,7 +224,8 @@ async def async_step_hassio_confirm( ) -> FlowResult: """Confirm a Hass.io discovery.""" errors: dict[str, str] = {} - assert self._hassio_discovery + if TYPE_CHECKING: + assert self._hassio_discovery if user_input is not None: data: dict[str, Any] = self._hassio_discovery.copy() @@ -312,7 +313,8 @@ async def async_step_options( def _birth_will(birt_or_will: str) -> dict[str, Any]: """Return the user input for birth or will.""" - assert user_input + if TYPE_CHECKING: + assert user_input return { ATTR_TOPIC: user_input[f"{birt_or_will}_topic"], ATTR_PAYLOAD: user_input.get(f"{birt_or_will}_payload", ""), diff --git a/homeassistant/components/mqtt/cover.py b/homeassistant/components/mqtt/cover.py index c11cf2dfb85d6e..3044e2d639648c 100644 --- a/homeassistant/components/mqtt/cover.py +++ b/homeassistant/components/mqtt/cover.py @@ -13,7 +13,6 @@ ATTR_POSITION, ATTR_TILT_POSITION, DEVICE_CLASSES_SCHEMA, - CoverDeviceClass, CoverEntity, CoverEntityFeature, ) @@ -295,6 +294,7 @@ def _setup_from_config(self, config: ConfigType) -> None: ): # Force into optimistic mode. self._optimistic = True + self._attr_assumed_state = bool(self._optimistic) if ( config[CONF_TILT_STATE_OPTIMISTIC] @@ -335,6 +335,25 @@ def _setup_from_config(self, config: ConfigType) -> None: config_attributes=template_config_attributes, ).async_render_with_possible_json_value + self._attr_device_class = self._config.get(CONF_DEVICE_CLASS) + + supported_features = CoverEntityFeature(0) + if self._config.get(CONF_COMMAND_TOPIC) is not None: + if self._config.get(CONF_PAYLOAD_OPEN) is not None: + supported_features |= CoverEntityFeature.OPEN + if self._config.get(CONF_PAYLOAD_CLOSE) is not None: + supported_features |= CoverEntityFeature.CLOSE + if self._config.get(CONF_PAYLOAD_STOP) is not None: + supported_features |= CoverEntityFeature.STOP + + if self._config.get(CONF_SET_POSITION_TOPIC) is not None: + supported_features |= CoverEntityFeature.SET_POSITION + + if self._config.get(CONF_TILT_COMMAND_TOPIC) is not None: + supported_features |= TILT_FEATURES + + self._attr_supported_features = supported_features + def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" topics = {} @@ -470,11 +489,6 @@ async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" await subscription.async_subscribe_topics(self.hass, self._sub_state) - @property - def assumed_state(self) -> bool: - """Return true if we do optimistic updates.""" - return bool(self._optimistic) - @property def is_closed(self) -> bool | None: """Return true if the cover is closed or None if the status is unknown.""" @@ -506,31 +520,6 @@ def current_cover_tilt_position(self) -> int | None: """Return current position of cover tilt.""" return self._tilt_value - @property - def device_class(self) -> CoverDeviceClass | None: - """Return the class of this sensor.""" - return self._config.get(CONF_DEVICE_CLASS) - - @property - def supported_features(self) -> CoverEntityFeature: - """Flag supported features.""" - supported_features = CoverEntityFeature(0) - if self._config.get(CONF_COMMAND_TOPIC) is not None: - if self._config.get(CONF_PAYLOAD_OPEN) is not None: - supported_features |= CoverEntityFeature.OPEN - if self._config.get(CONF_PAYLOAD_CLOSE) is not None: - supported_features |= CoverEntityFeature.CLOSE - if self._config.get(CONF_PAYLOAD_STOP) is not None: - supported_features |= CoverEntityFeature.STOP - - if self._config.get(CONF_SET_POSITION_TOPIC) is not None: - supported_features |= CoverEntityFeature.SET_POSITION - - if self._config.get(CONF_TILT_COMMAND_TOPIC) is not None: - supported_features |= TILT_FEATURES - - return supported_features - async def async_open_cover(self, **kwargs: Any) -> None: """Move the cover up. diff --git a/homeassistant/components/mqtt/debug_info.py b/homeassistant/components/mqtt/debug_info.py index bdbdd74de96797..6b4b90586a7287 100644 --- a/homeassistant/components/mqtt/debug_info.py +++ b/homeassistant/components/mqtt/debug_info.py @@ -5,7 +5,7 @@ from collections.abc import Callable import datetime as dt from functools import wraps -from typing import Any +from typing import TYPE_CHECKING, Any import attr @@ -128,11 +128,11 @@ def update_entity_discovery_data( hass: HomeAssistant, discovery_payload: DiscoveryInfoType, entity_id: str ) -> None: """Update discovery data.""" - assert ( - discovery_data := get_mqtt_data(hass).debug_info_entities[entity_id][ - "discovery_data" - ] - ) is not None + discovery_data = get_mqtt_data(hass).debug_info_entities[entity_id][ + "discovery_data" + ] + if TYPE_CHECKING: + assert discovery_data is not None discovery_data[ATTR_DISCOVERY_PAYLOAD] = discovery_payload diff --git a/homeassistant/components/mqtt/device_trigger.py b/homeassistant/components/mqtt/device_trigger.py index 36291ae0be836d..fc7528743faea6 100644 --- a/homeassistant/components/mqtt/device_trigger.py +++ b/homeassistant/components/mqtt/device_trigger.py @@ -3,7 +3,7 @@ from collections.abc import Callable import logging -from typing import Any +from typing import TYPE_CHECKING, Any import attr import voluptuous as vol @@ -269,7 +269,8 @@ async def async_setup_trigger( config = TRIGGER_DISCOVERY_SCHEMA(config) device_id = update_device(hass, config_entry, config) - assert isinstance(device_id, str) + if TYPE_CHECKING: + assert isinstance(device_id, str) mqtt_device_trigger = MqttDeviceTrigger( hass, config, device_id, discovery_data, config_entry ) @@ -286,7 +287,8 @@ async def async_removed_from_device(hass: HomeAssistant, device_id: str) -> None if device_trigger: device_trigger.detach_trigger() discovery_data = device_trigger.discovery_data - assert discovery_data is not None + if TYPE_CHECKING: + assert discovery_data is not None discovery_hash = discovery_data[ATTR_DISCOVERY_HASH] debug_info.remove_trigger_discovery_data(hass, discovery_hash) diff --git a/homeassistant/components/mqtt/diagnostics.py b/homeassistant/components/mqtt/diagnostics.py index 173c583ca6a4bc..82bae04d2c9a30 100644 --- a/homeassistant/components/mqtt/diagnostics.py +++ b/homeassistant/components/mqtt/diagnostics.py @@ -1,7 +1,7 @@ """Diagnostics support for MQTT.""" from __future__ import annotations -from typing import Any +from typing import TYPE_CHECKING, Any from homeassistant.components import device_tracker from homeassistant.components.diagnostics import async_redact_data @@ -45,7 +45,8 @@ def _async_get_diagnostics( ) -> dict[str, Any]: """Return diagnostics for a config entry.""" mqtt_instance = get_mqtt_data(hass).client - assert mqtt_instance is not None + if TYPE_CHECKING: + assert mqtt_instance is not None redacted_config = async_redact_data(mqtt_instance.conf, REDACT_CONFIG) diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index 0002a1866a4d5f..c78319bb46a58b 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -7,7 +7,7 @@ import logging import re import time -from typing import Any +from typing import TYPE_CHECKING, Any import voluptuous as vol @@ -275,32 +275,34 @@ def async_process_discovery_payload( _LOGGER.debug("Process discovery payload %s", payload) discovery_hash = (component, discovery_id) - if discovery_hash in mqtt_data.discovery_already_discovered or payload: + + already_discovered = discovery_hash in mqtt_data.discovery_already_discovered + if ( + already_discovered or payload + ) and discovery_hash not in mqtt_data.discovery_pending_discovered: + discovery_pending_discovered = mqtt_data.discovery_pending_discovered @callback def discovery_done(_: Any) -> None: - pending = mqtt_data.discovery_pending_discovered[discovery_hash][ - "pending" - ] + pending = discovery_pending_discovered[discovery_hash]["pending"] _LOGGER.debug("Pending discovery for %s: %s", discovery_hash, pending) if not pending: - mqtt_data.discovery_pending_discovered[discovery_hash]["unsub"]() - mqtt_data.discovery_pending_discovered.pop(discovery_hash) + discovery_pending_discovered[discovery_hash]["unsub"]() + discovery_pending_discovered.pop(discovery_hash) else: payload = pending.pop() async_process_discovery_payload(component, discovery_id, payload) - if discovery_hash not in mqtt_data.discovery_pending_discovered: - mqtt_data.discovery_pending_discovered[discovery_hash] = { - "unsub": async_dispatcher_connect( - hass, - MQTT_DISCOVERY_DONE.format(discovery_hash), - discovery_done, - ), - "pending": deque([]), - } + discovery_pending_discovered[discovery_hash] = { + "unsub": async_dispatcher_connect( + hass, + MQTT_DISCOVERY_DONE.format(discovery_hash), + discovery_done, + ), + "pending": deque([]), + } - if discovery_hash in mqtt_data.discovery_already_discovered: + if already_discovered: # Dispatch update message = f"Component has already been discovered: {component} {discovery_id}, sending update" async_log_discovery_origin_info(message, payload) @@ -341,7 +343,8 @@ async def async_integration_message_received( integration: str, msg: ReceiveMessage ) -> None: """Process the received message.""" - assert mqtt_data.data_config_flow_lock + if TYPE_CHECKING: + assert mqtt_data.data_config_flow_lock key = f"{integration}_{msg.subscribed_topic}" # Lock to prevent initiating many parallel config flows. diff --git a/homeassistant/components/mqtt/fan.py b/homeassistant/components/mqtt/fan.py index 58189c3cb3e8c3..5c7557c7598d81 100644 --- a/homeassistant/components/mqtt/fan.py +++ b/homeassistant/components/mqtt/fan.py @@ -295,6 +295,7 @@ def _setup_from_config(self, config: ConfigType) -> None: optimistic = config[CONF_OPTIMISTIC] self._optimistic = optimistic or self._topic[CONF_STATE_TOPIC] is None + self._attr_assumed_state = bool(self._optimistic) self._optimistic_direction = ( optimistic or self._topic[CONF_DIRECTION_STATE_TOPIC] is None ) @@ -491,11 +492,6 @@ async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" await subscription.async_subscribe_topics(self.hass, self._sub_state) - @property - def assumed_state(self) -> bool: - """Return true if we do optimistic updates.""" - return self._optimistic - @property def is_on(self) -> bool | None: """Return true if device is on.""" diff --git a/homeassistant/components/mqtt/humidifier.py b/homeassistant/components/mqtt/humidifier.py index aebb05c19f7cd7..52d8db3fc9822c 100644 --- a/homeassistant/components/mqtt/humidifier.py +++ b/homeassistant/components/mqtt/humidifier.py @@ -260,6 +260,7 @@ def _setup_from_config(self, config: ConfigType) -> None: optimistic: bool = config[CONF_OPTIMISTIC] self._optimistic = optimistic or self._topic[CONF_STATE_TOPIC] is None + self._attr_assumed_state = bool(self._optimistic) self._optimistic_target_humidity = ( optimistic or self._topic[CONF_TARGET_HUMIDITY_STATE_TOPIC] is None ) @@ -465,11 +466,6 @@ async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" await subscription.async_subscribe_topics(self.hass, self._sub_state) - @property - def assumed_state(self) -> bool: - """Return true if we do optimistic updates.""" - return self._optimistic - async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the entity. diff --git a/homeassistant/components/mqtt/image.py b/homeassistant/components/mqtt/image.py index da62416d29e313..da526575a77191 100644 --- a/homeassistant/components/mqtt/image.py +++ b/homeassistant/components/mqtt/image.py @@ -6,7 +6,7 @@ from collections.abc import Callable import functools import logging -from typing import Any +from typing import TYPE_CHECKING, Any import httpx import voluptuous as vol @@ -172,7 +172,8 @@ def image_data_received(msg: ReceiveMessage) -> None: if CONF_IMAGE_ENCODING in self._config: self._last_image = b64decode(msg.payload) else: - assert isinstance(msg.payload, bytes) + if TYPE_CHECKING: + assert isinstance(msg.payload, bytes) self._last_image = msg.payload except (binascii.Error, ValueError, AssertionError) as err: _LOGGER.error( diff --git a/homeassistant/components/mqtt/lawn_mower.py b/homeassistant/components/mqtt/lawn_mower.py index 44db3581f8bc1a..fc3996ffbffb65 100644 --- a/homeassistant/components/mqtt/lawn_mower.py +++ b/homeassistant/components/mqtt/lawn_mower.py @@ -113,7 +113,6 @@ class MqttLawnMower(MqttEntity, LawnMowerEntity, RestoreEntity): _command_templates: dict[str, Callable[[PublishPayloadType], PublishPayloadType]] _command_topics: dict[str, str] _value_template: Callable[[ReceivePayloadType], ReceivePayloadType] - _optimistic: bool = False def __init__( self, @@ -134,7 +133,7 @@ def config_schema() -> vol.Schema: def _setup_from_config(self, config: ConfigType) -> None: """(Re)Setup the entity.""" - self._optimistic = config[CONF_OPTIMISTIC] + self._attr_assumed_state = config[CONF_OPTIMISTIC] self._value_template = MqttValueTemplate( config.get(CONF_ACTIVITY_VALUE_TEMPLATE), entity=self @@ -198,7 +197,7 @@ def message_received(msg: ReceiveMessage) -> None: if self._config.get(CONF_ACTIVITY_STATE_TOPIC) is None: # Force into optimistic mode. - self._optimistic = True + self._attr_assumed_state = True else: self._sub_state = subscription.async_prepare_subscribe_topics( self.hass, @@ -217,19 +216,16 @@ async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" await subscription.async_subscribe_topics(self.hass, self._sub_state) - if self._optimistic and (last_state := await self.async_get_last_state()): + if self._attr_assumed_state and ( + last_state := await self.async_get_last_state() + ): with contextlib.suppress(ValueError): self._attr_activity = LawnMowerActivity(last_state.state) - @property - def assumed_state(self) -> bool: - """Return true if we do optimistic updates.""" - return self._optimistic - async def _async_operate(self, option: str, activity: LawnMowerActivity) -> None: """Execute operation.""" payload = self._command_templates[option](option) - if self._optimistic: + if self._attr_assumed_state: self._attr_activity = activity self.async_write_ha_state() diff --git a/homeassistant/components/mqtt/light/schema_basic.py b/homeassistant/components/mqtt/light/schema_basic.py index 2a726075bb0da2..34b4a567ba53dd 100644 --- a/homeassistant/components/mqtt/light/schema_basic.py +++ b/homeassistant/components/mqtt/light/schema_basic.py @@ -330,6 +330,7 @@ def _setup_from_config(self, config: ConfigType) -> None: optimistic or topic[CONF_COLOR_MODE_STATE_TOPIC] is None ) self._optimistic = optimistic or topic[CONF_STATE_TOPIC] is None + self._attr_assumed_state = bool(self._optimistic) self._optimistic_rgb_color = optimistic or topic[CONF_RGB_STATE_TOPIC] is None self._optimistic_rgbw_color = optimistic or topic[CONF_RGBW_STATE_TOPIC] is None self._optimistic_rgbww_color = ( @@ -668,11 +669,6 @@ def restore_state( restore_state(ATTR_XY_COLOR) restore_state(ATTR_HS_COLOR, ATTR_XY_COLOR) - @property - def assumed_state(self) -> bool: - """Return true if we do optimistic updates.""" - return self._optimistic - async def async_turn_on(self, **kwargs: Any) -> None: # noqa: C901 """Turn the device on. diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py index b778791216177b..11574b8879858b 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -215,6 +215,7 @@ def _setup_from_config(self, config: ConfigType) -> None: } optimistic: bool = config[CONF_OPTIMISTIC] self._optimistic = optimistic or self._topic[CONF_STATE_TOPIC] is None + self._attr_assumed_state = bool(self._optimistic) self._flash_times = { key: config.get(key) @@ -462,11 +463,6 @@ async def _subscribe_topics(self) -> None: ) self._attr_xy_color = last_attributes.get(ATTR_XY_COLOR, self.xy_color) - @property - def assumed_state(self) -> bool: - """Return true if we do optimistic updates.""" - return self._optimistic - @property def color_mode(self) -> ColorMode | str | None: """Return current color mode.""" diff --git a/homeassistant/components/mqtt/light/schema_template.py b/homeassistant/components/mqtt/light/schema_template.py index 98ee7648eebd85..e811c45fc67181 100644 --- a/homeassistant/components/mqtt/light/schema_template.py +++ b/homeassistant/components/mqtt/light/schema_template.py @@ -179,6 +179,7 @@ def _setup_from_config(self, config: ConfigType) -> None: or self._topics[CONF_STATE_TOPIC] is None or CONF_STATE_TEMPLATE not in self._config ) + self._attr_assumed_state = bool(self._optimistic) color_modes = {ColorMode.ONOFF} if CONF_BRIGHTNESS_TEMPLATE in config: @@ -315,11 +316,6 @@ async def _subscribe_topics(self) -> None: if last_state.attributes.get(ATTR_EFFECT): self._attr_effect = last_state.attributes.get(ATTR_EFFECT) - @property - def assumed_state(self) -> bool: - """Return True if unable to access real state of the entity.""" - return self._optimistic - async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on. diff --git a/homeassistant/components/mqtt/lock.py b/homeassistant/components/mqtt/lock.py index cb586c0630929f..d2e67ba40da3b2 100644 --- a/homeassistant/components/mqtt/lock.py +++ b/homeassistant/components/mqtt/lock.py @@ -159,6 +159,7 @@ def _setup_from_config(self, config: ConfigType) -> None: self._optimistic = ( config[CONF_OPTIMISTIC] or self._config.get(CONF_STATE_TOPIC) is None ) + self._attr_assumed_state = bool(self._optimistic) self._compiled_pattern = config.get(CONF_CODE_FORMAT) self._attr_code_format = ( @@ -221,11 +222,6 @@ async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" await subscription.async_subscribe_topics(self.hass, self._sub_state) - @property - def assumed_state(self) -> bool: - """Return true if we do optimistic updates.""" - return self._optimistic - async def async_lock(self, **kwargs: Any) -> None: """Lock the device. diff --git a/homeassistant/components/mqtt/mixins.py b/homeassistant/components/mqtt/mixins.py index 3b28bc8804f4a7..a01691f0601488 100644 --- a/homeassistant/components/mqtt/mixins.py +++ b/homeassistant/components/mqtt/mixins.py @@ -4,9 +4,9 @@ from abc import ABC, abstractmethod import asyncio from collections.abc import Callable, Coroutine -from functools import partial +from functools import partial, wraps import logging -from typing import Any, Protocol, cast, final +from typing import TYPE_CHECKING, Any, Protocol, cast, final import voluptuous as vol @@ -101,6 +101,7 @@ set_discovery_hash, ) from .models import ( + MessageCallbackType, MqttValueTemplate, PublishPayloadType, ReceiveMessage, @@ -346,6 +347,41 @@ def init_entity_id_from_config( ) +def write_state_on_attr_change( + entity: Entity, attributes: set[str] +) -> Callable[[MessageCallbackType], MessageCallbackType]: + """Wrap an MQTT message callback to track state attribute changes.""" + + def _attrs_have_changed(tracked_attrs: dict[str, Any]) -> bool: + """Return True if attributes on entity changed or if update is forced.""" + if not (write_state := (getattr(entity, "_attr_force_update", False))): + for attribute, last_value in tracked_attrs.items(): + if getattr(entity, attribute, UNDEFINED) != last_value: + write_state = True + break + + return write_state + + def _decorator(msg_callback: MessageCallbackType) -> MessageCallbackType: + @wraps(msg_callback) + def wrapper(msg: ReceiveMessage) -> None: + """Track attributes for write state requests.""" + tracked_attrs: dict[str, Any] = { + attribute: getattr(entity, attribute, UNDEFINED) + for attribute in attributes + } + msg_callback(msg) + if not _attrs_have_changed(tracked_attrs): + return + + mqtt_data = get_mqtt_data(entity.hass) + mqtt_data.state_write_requests.write_state_request(entity) + + return wrapper + + return _decorator + + class MqttAttributes(Entity): """Mixin used for platforms that support JSON attributes.""" @@ -379,6 +415,7 @@ def _attributes_prepare_subscribe_topics(self) -> None: @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_extra_state_attributes"}) def attributes_message_received(msg: ReceiveMessage) -> None: try: payload = attr_tpl(msg.payload) @@ -391,9 +428,6 @@ def attributes_message_received(msg: ReceiveMessage) -> None: and k not in self._attributes_extra_blocked } self._attr_extra_state_attributes = filtered_dict - get_mqtt_data(self.hass).state_write_requests.write_state_request( - self - ) else: _LOGGER.warning("JSON result was not a dictionary") except ValueError: @@ -488,6 +522,7 @@ def _availability_prepare_subscribe_topics(self) -> None: @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"available"}) def availability_message_received(msg: ReceiveMessage) -> None: """Handle a new received MQTT availability message.""" topic = msg.topic @@ -500,8 +535,6 @@ def availability_message_received(msg: ReceiveMessage) -> None: self._available[topic] = False self._available_latest = False - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) - self._available = { topic: (self._available[topic] if topic in self._available else False) for topic in self._avail_topics @@ -850,7 +883,8 @@ def discovery_callback(payload: MQTTDiscoveryPayload) -> None: discovery_hash, payload, ) - assert self._discovery_data + if TYPE_CHECKING: + assert self._discovery_data old_payload: DiscoveryInfoType old_payload = self._discovery_data[ATTR_DISCOVERY_PAYLOAD] debug_info.update_entity_discovery_data(self.hass, payload, self.entity_id) @@ -877,7 +911,8 @@ def discovery_callback(payload: MQTTDiscoveryPayload) -> None: send_discovery_done(self.hass, self._discovery_data) if discovery_hash: - assert self._discovery_data is not None + if TYPE_CHECKING: + assert self._discovery_data is not None debug_info.add_entity_discovery_data( self.hass, self._discovery_data, self.entity_id ) @@ -1135,6 +1170,11 @@ def _set_entity_name(self, config: ConfigType) -> None: elif not self._default_to_device_class_name(): # Assign the default name self._attr_name = self._default_name + elif hasattr(self, "_attr_name"): + # An entity name was not set in the config + # don't set the name attribute and derive + # the name from the device_class + delattr(self, "_attr_name") if CONF_DEVICE in config: device_name: str if CONF_NAME not in config[CONF_DEVICE]: diff --git a/homeassistant/components/mqtt/number.py b/homeassistant/components/mqtt/number.py index 971b44b43bfccd..a88210a31980a2 100644 --- a/homeassistant/components/mqtt/number.py +++ b/homeassistant/components/mqtt/number.py @@ -161,7 +161,7 @@ def config_schema() -> vol.Schema: def _setup_from_config(self, config: ConfigType) -> None: """(Re)Setup the entity.""" self._config = config - self._optimistic = config[CONF_OPTIMISTIC] + self._attr_assumed_state = config[CONF_OPTIMISTIC] self._command_template = MqttCommandTemplate( config.get(CONF_COMMAND_TEMPLATE), entity=self @@ -218,7 +218,7 @@ def message_received(msg: ReceiveMessage) -> None: if self._config.get(CONF_STATE_TOPIC) is None: # Force into optimistic mode. - self._optimistic = True + self._attr_assumed_state = True else: self._sub_state = subscription.async_prepare_subscribe_topics( self.hass, @@ -237,7 +237,7 @@ async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" await subscription.async_subscribe_topics(self.hass, self._sub_state) - if self._optimistic and ( + if self._attr_assumed_state and ( last_number_data := await self.async_get_last_number_data() ): self._attr_native_value = last_number_data.native_value @@ -250,7 +250,7 @@ async def async_set_native_value(self, value: float) -> None: current_number = int(value) payload = self._command_template(current_number) - if self._optimistic: + if self._attr_assumed_state: self._attr_native_value = current_number self.async_write_ha_state() @@ -261,8 +261,3 @@ async def async_set_native_value(self, value: float) -> None: self._config[CONF_RETAIN], self._config[CONF_ENCODING], ) - - @property - def assumed_state(self) -> bool: - """Return true if we do optimistic updates.""" - return self._optimistic diff --git a/homeassistant/components/mqtt/select.py b/homeassistant/components/mqtt/select.py index df8cf024bd26c3..1c4b33de0ee9d1 100644 --- a/homeassistant/components/mqtt/select.py +++ b/homeassistant/components/mqtt/select.py @@ -115,7 +115,7 @@ def config_schema() -> vol.Schema: def _setup_from_config(self, config: ConfigType) -> None: """(Re)Setup the entity.""" - self._optimistic = config[CONF_OPTIMISTIC] + self._attr_assumed_state = config[CONF_OPTIMISTIC] self._attr_options = config[CONF_OPTIONS] self._command_template = MqttCommandTemplate( @@ -152,7 +152,7 @@ def message_received(msg: ReceiveMessage) -> None: if self._config.get(CONF_STATE_TOPIC) is None: # Force into optimistic mode. - self._optimistic = True + self._attr_assumed_state = True else: self._sub_state = subscription.async_prepare_subscribe_topics( self.hass, @@ -171,13 +171,15 @@ async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" await subscription.async_subscribe_topics(self.hass, self._sub_state) - if self._optimistic and (last_state := await self.async_get_last_state()): + if self._attr_assumed_state and ( + last_state := await self.async_get_last_state() + ): self._attr_current_option = last_state.state async def async_select_option(self, option: str) -> None: """Update the current value.""" payload = self._command_template(option) - if self._optimistic: + if self._attr_assumed_state: self._attr_current_option = option self.async_write_ha_state() @@ -188,8 +190,3 @@ async def async_select_option(self, option: str) -> None: self._config[CONF_RETAIN], self._config[CONF_ENCODING], ) - - @property - def assumed_state(self) -> bool: - """Return true if we do optimistic updates.""" - return self._optimistic diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index ae94b0df0ce68c..278e70a9737995 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -32,7 +32,7 @@ from homeassistant.core import CALLBACK_TYPE, HomeAssistant, State, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import async_track_point_in_utc_time +from homeassistant.helpers.event import async_call_later from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util @@ -45,6 +45,7 @@ MqttAvailability, MqttEntity, async_setup_entry_helper, + write_state_on_attr_change, ) from .models import ( MqttValueTemplate, @@ -52,7 +53,6 @@ ReceiveMessage, ReceivePayloadType, ) -from .util import get_mqtt_data _LOGGER = logging.getLogger(__name__) @@ -162,15 +162,17 @@ async def mqtt_async_added_to_hass(self) -> None: and not self._expiration_trigger ): expiration_at = last_state.last_changed + timedelta(seconds=_expire_after) - if expiration_at < (time_now := dt_util.utcnow()): + remain_seconds = (expiration_at - dt_util.utcnow()).total_seconds() + + if remain_seconds <= 0: # Skip reactivating the sensor _LOGGER.debug("Skip state recovery after reload for %s", self.entity_id) return self._expired = False self._attr_native_value = last_sensor_data.native_value - self._expiration_trigger = async_track_point_in_utc_time( - self.hass, self._value_is_expired, expiration_at + self._expiration_trigger = async_call_later( + self.hass, remain_seconds, self._value_is_expired ) _LOGGER.debug( ( @@ -178,7 +180,7 @@ async def mqtt_async_added_to_hass(self) -> None: " expiring %s" ), self.entity_id, - expiration_at - time_now, + remain_seconds, ) async def async_will_remove_from_hass(self) -> None: @@ -235,10 +237,8 @@ def _update_state(msg: ReceiveMessage) -> None: self._expiration_trigger() # Set new trigger - expiration_at = dt_util.utcnow() + timedelta(seconds=self._expire_after) - - self._expiration_trigger = async_track_point_in_utc_time( - self.hass, self._value_is_expired, expiration_at + self._expiration_trigger = async_call_later( + self.hass, self._expire_after, self._value_is_expired ) payload = self._template(msg.payload, PayloadSentinel.DEFAULT) @@ -287,13 +287,13 @@ def _update_last_reset(msg: ReceiveMessage) -> None: ) @callback + @write_state_on_attr_change(self, {"_attr_native_value", "_attr_last_reset"}) @log_messages(self.hass, self.entity_id) def message_received(msg: ReceiveMessage) -> None: """Handle new MQTT messages.""" _update_state(msg) if CONF_LAST_RESET_VALUE_TEMPLATE in self._config: _update_last_reset(msg) - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) topics["state_topic"] = { "topic": self._config[CONF_STATE_TOPIC], diff --git a/homeassistant/components/mqtt/siren.py b/homeassistant/components/mqtt/siren.py index 328812a6e49ebe..aeabd0fe148007 100644 --- a/homeassistant/components/mqtt/siren.py +++ b/homeassistant/components/mqtt/siren.py @@ -194,6 +194,7 @@ def _setup_from_config(self, config: ConfigType) -> None: self._attr_supported_features = _supported_features self._optimistic = config[CONF_OPTIMISTIC] or CONF_STATE_TOPIC not in config + self._attr_assumed_state = bool(self._optimistic) self._attr_is_on = False if self._optimistic else None command_template: Template | None = config.get(CONF_COMMAND_TEMPLATE) @@ -301,11 +302,6 @@ async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" await subscription.async_subscribe_topics(self.hass, self._sub_state) - @property - def assumed_state(self) -> bool: - """Return true if we do optimistic updates.""" - return self._optimistic - @property def extra_state_attributes(self) -> dict[str, Any] | None: """Return the state attributes.""" diff --git a/homeassistant/components/mqtt/subscription.py b/homeassistant/components/mqtt/subscription.py index dda80bba84e20a..3f8f0f4ee3ec3e 100644 --- a/homeassistant/components/mqtt/subscription.py +++ b/homeassistant/components/mqtt/subscription.py @@ -2,7 +2,7 @@ from __future__ import annotations from collections.abc import Callable, Coroutine -from typing import Any +from typing import TYPE_CHECKING, Any import attr @@ -31,7 +31,8 @@ def resubscribe_if_necessary( ) -> None: """Re-subscribe to the new topic if necessary.""" if not self._should_resubscribe(other): - assert other + if TYPE_CHECKING: + assert other self.unsubscribe_callback = other.unsubscribe_callback return diff --git a/homeassistant/components/mqtt/switch.py b/homeassistant/components/mqtt/switch.py index 107b0b1cb10d99..e8872d3f0d1e96 100644 --- a/homeassistant/components/mqtt/switch.py +++ b/homeassistant/components/mqtt/switch.py @@ -125,6 +125,7 @@ def _setup_from_config(self, config: ConfigType) -> None: self._optimistic = ( config[CONF_OPTIMISTIC] or config.get(CONF_STATE_TOPIC) is None ) + self._attr_assumed_state = bool(self._optimistic) self._value_template = MqttValueTemplate( self._config.get(CONF_VALUE_TEMPLATE), entity=self @@ -171,11 +172,6 @@ async def _subscribe_topics(self) -> None: if self._optimistic and (last_state := await self.async_get_last_state()): self._attr_is_on = last_state.state == STATE_ON - @property - def assumed_state(self) -> bool: - """Return true if we do optimistic updates.""" - return self._optimistic - async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on. diff --git a/homeassistant/components/mqtt/text.py b/homeassistant/components/mqtt/text.py index 13677b7f35b223..6d1196cfd957f9 100644 --- a/homeassistant/components/mqtt/text.py +++ b/homeassistant/components/mqtt/text.py @@ -169,6 +169,7 @@ def _setup_from_config(self, config: ConfigType) -> None: ).async_render_with_possible_json_value optimistic: bool = config[CONF_OPTIMISTIC] self._optimistic = optimistic or config.get(CONF_STATE_TOPIC) is None + self._attr_assumed_state = bool(self._optimistic) def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" @@ -203,11 +204,6 @@ async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" await subscription.async_subscribe_topics(self.hass, self._sub_state) - @property - def assumed_state(self) -> bool: - """Return true if we do optimistic updates.""" - return self._optimistic - async def async_set_value(self, value: str) -> None: """Change the text.""" payload = self._command_template(value) diff --git a/homeassistant/components/mqtt/util.py b/homeassistant/components/mqtt/util.py index 02d9964bcd14e1..6e364182cb02a9 100644 --- a/homeassistant/components/mqtt/util.py +++ b/homeassistant/components/mqtt/util.py @@ -63,7 +63,9 @@ async def async_wait_for_mqtt_client(hass: HomeAssistant) -> bool: state_reached_future: asyncio.Future[bool] if DATA_MQTT_AVAILABLE not in hass.data: - hass.data[DATA_MQTT_AVAILABLE] = state_reached_future = asyncio.Future() + hass.data[ + DATA_MQTT_AVAILABLE + ] = state_reached_future = hass.loop.create_future() else: state_reached_future = hass.data[DATA_MQTT_AVAILABLE] if state_reached_future.done(): diff --git a/homeassistant/components/mqtt_room/sensor.py b/homeassistant/components/mqtt_room/sensor.py index 1b4cdb1c58317d..4eb3a3f5171794 100644 --- a/homeassistant/components/mqtt_room/sensor.py +++ b/homeassistant/components/mqtt_room/sensor.py @@ -2,8 +2,9 @@ from __future__ import annotations from datetime import timedelta -import json +from functools import lru_cache import logging +from typing import Any import voluptuous as vol @@ -24,6 +25,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util, slugify +from homeassistant.util.json import json_loads _LOGGER = logging.getLogger(__name__) @@ -47,9 +49,16 @@ } ).extend(mqtt.config.MQTT_RO_SCHEMA.schema) + +@lru_cache(maxsize=256) +def _slugify_upper(string: str) -> str: + """Return a slugified version of string, uppercased.""" + return slugify(string).upper() + + MQTT_PAYLOAD = vol.Schema( vol.All( - json.loads, + json_loads, vol.Schema( { vol.Required(ATTR_ID): cv.string, @@ -106,7 +115,7 @@ def __init__( self._state = STATE_NOT_HOME self._name = name self._state_topic = f"{state_topic}/+" - self._device_id = slugify(device_id).upper() + self._device_id = _slugify_upper(device_id) self._timeout = timeout self._consider_home = ( timedelta(seconds=consider_home) if consider_home else None @@ -179,11 +188,10 @@ def update(self) -> None: self._state = STATE_NOT_HOME -def _parse_update_data(topic, data): +def _parse_update_data(topic: str, data: dict[str, Any]) -> dict[str, Any]: """Parse the room presence update.""" parts = topic.split("/") room = parts[-1] - device_id = slugify(data.get(ATTR_ID)).upper() + device_id = _slugify_upper(data.get(ATTR_ID)) distance = data.get("distance") - parsed_data = {ATTR_DEVICE_ID: device_id, ATTR_ROOM: room, ATTR_DISTANCE: distance} - return parsed_data + return {ATTR_DEVICE_ID: device_id, ATTR_ROOM: room, ATTR_DISTANCE: distance} diff --git a/homeassistant/components/mutesync/binary_sensor.py b/homeassistant/components/mutesync/binary_sensor.py index 444643d5333acb..910f91fc4c60fc 100644 --- a/homeassistant/components/mutesync/binary_sensor.py +++ b/homeassistant/components/mutesync/binary_sensor.py @@ -36,24 +36,17 @@ def __init__(self, coordinator, sensor_type): super().__init__(coordinator) self._sensor_type = sensor_type self._attr_translation_key = sensor_type - - @property - def unique_id(self): - """Return the unique ID of the sensor.""" - return f"{self.coordinator.data['user-id']}-{self._sensor_type}" - - @property - def is_on(self): - """Return the state of the sensor.""" - return self.coordinator.data[self._sensor_type] - - @property - def device_info(self) -> DeviceInfo: - """Return the device info of the sensor.""" - return DeviceInfo( + user_id = coordinator.data["user-id"] + self._attr_unique_id = f"{user_id}-{sensor_type}" + self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, self.coordinator.data["user-id"])}, + identifiers={(DOMAIN, user_id)}, manufacturer="mütesync", model="mutesync app", name="mutesync", ) + + @property + def is_on(self): + """Return the state of the sensor.""" + return self.coordinator.data[self._sensor_type] diff --git a/homeassistant/components/nanoleaf/light.py b/homeassistant/components/nanoleaf/light.py index f0425594763e12..dc251ac1e5d45a 100644 --- a/homeassistant/components/nanoleaf/light.py +++ b/homeassistant/components/nanoleaf/light.py @@ -47,6 +47,7 @@ class NanoleafLight(NanoleafEntity, LightEntity): _attr_supported_color_modes = {ColorMode.COLOR_TEMP, ColorMode.HS} _attr_supported_features = LightEntityFeature.EFFECT | LightEntityFeature.TRANSITION _attr_name = None + _attr_icon = "mdi:triangle-outline" def __init__( self, nanoleaf: Nanoleaf, coordinator: DataUpdateCoordinator[None] @@ -83,11 +84,6 @@ def effect_list(self) -> list[str]: """Return the list of supported effects.""" return self._nanoleaf.effects_list - @property - def icon(self) -> str: - """Return the icon to use in the frontend, if any.""" - return "mdi:triangle-outline" - @property def is_on(self) -> bool: """Return true if light is on.""" diff --git a/homeassistant/components/neato/camera.py b/homeassistant/components/neato/camera.py index c1513bb1de6a27..9ce66a53622331 100644 --- a/homeassistant/components/neato/camera.py +++ b/homeassistant/components/neato/camera.py @@ -57,6 +57,7 @@ def __init__( self._mapdata = mapdata self._available = neato is not None self._robot_serial: str = self.robot.serial + self._attr_unique_id = self.robot.serial self._generated_at: str | None = None self._image_url: str | None = None self._image: bytes | None = None @@ -109,11 +110,6 @@ def update(self) -> None: self._generated_at = map_data.get("generated_at") self._available = True - @property - def unique_id(self) -> str: - """Return unique ID.""" - return self._robot_serial - @property def available(self) -> bool: """Return if the robot is available.""" diff --git a/homeassistant/components/neato/entity.py b/homeassistant/components/neato/entity.py index 43072f1969391f..46ad358c63800b 100644 --- a/homeassistant/components/neato/entity.py +++ b/homeassistant/components/neato/entity.py @@ -17,11 +17,7 @@ class NeatoEntity(Entity): def __init__(self, robot: Robot) -> None: """Initialize Neato entity.""" self.robot = robot - - @property - def device_info(self) -> DeviceInfo: - """Return device info.""" - return DeviceInfo( + self._attr_device_info: DeviceInfo = DeviceInfo( identifiers={(NEATO_DOMAIN, self.robot.serial)}, name=self.robot.name, ) diff --git a/homeassistant/components/neato/sensor.py b/homeassistant/components/neato/sensor.py index 452f1bc3a9c740..3b68ddcf3dfef3 100644 --- a/homeassistant/components/neato/sensor.py +++ b/homeassistant/components/neato/sensor.py @@ -44,11 +44,16 @@ async def async_setup_entry( class NeatoSensor(NeatoEntity, SensorEntity): """Neato sensor.""" + _attr_device_class = SensorDeviceClass.BATTERY + _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_native_unit_of_measurement = PERCENTAGE + _attr_available: bool = False + def __init__(self, neato: NeatoHub, robot: Robot) -> None: """Initialize Neato sensor.""" super().__init__(robot) - self._available: bool = False self._robot_serial: str = self.robot.serial + self._attr_unique_id = self.robot.serial self._state: dict[str, Any] | None = None def update(self) -> None: @@ -56,45 +61,20 @@ def update(self) -> None: try: self._state = self.robot.state except NeatoRobotException as ex: - if self._available: + if self._attr_available: _LOGGER.error( "Neato sensor connection error for '%s': %s", self.entity_id, ex ) self._state = None - self._available = False + self._attr_available = False return - self._available = True + self._attr_available = True _LOGGER.debug("self._state=%s", self._state) - @property - def unique_id(self) -> str: - """Return unique ID.""" - return self._robot_serial - - @property - def device_class(self) -> SensorDeviceClass: - """Return the device class.""" - return SensorDeviceClass.BATTERY - - @property - def entity_category(self) -> EntityCategory: - """Device entity category.""" - return EntityCategory.DIAGNOSTIC - - @property - def available(self) -> bool: - """Return availability.""" - return self._available - @property def native_value(self) -> str | None: """Return the state.""" if self._state is not None: return str(self._state["details"]["charge"]) return None - - @property - def native_unit_of_measurement(self) -> str: - """Return unit of measurement.""" - return PERCENTAGE diff --git a/homeassistant/components/neato/switch.py b/homeassistant/components/neato/switch.py index a80d05eef2367c..ae90a8230b2876 100644 --- a/homeassistant/components/neato/switch.py +++ b/homeassistant/components/neato/switch.py @@ -49,16 +49,17 @@ class NeatoConnectedSwitch(NeatoEntity, SwitchEntity): """Neato Connected Switches.""" _attr_translation_key = "schedule" + _attr_available = False + _attr_entity_category = EntityCategory.CONFIG def __init__(self, neato: NeatoHub, robot: Robot, switch_type: str) -> None: """Initialize the Neato Connected switches.""" super().__init__(robot) self.type = switch_type - self._available = False self._state: dict[str, Any] | None = None self._schedule_state: str | None = None self._clean_state = None - self._robot_serial: str = self.robot.serial + self._attr_unique_id = self.robot.serial def update(self) -> None: """Update the states of Neato switches.""" @@ -66,15 +67,15 @@ def update(self) -> None: try: self._state = self.robot.state except NeatoRobotException as ex: - if self._available: # Print only once when available + if self._attr_available: # Print only once when available _LOGGER.error( "Neato switch connection error for '%s': %s", self.entity_id, ex ) self._state = None - self._available = False + self._attr_available = False return - self._available = True + self._attr_available = True _LOGGER.debug("self._state=%s", self._state) if self.type == SWITCH_TYPE_SCHEDULE: _LOGGER.debug("State: %s", self._state) @@ -86,16 +87,6 @@ def update(self) -> None: "Schedule state for '%s': %s", self.entity_id, self._schedule_state ) - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self._available - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return self._robot_serial - @property def is_on(self) -> bool: """Return true if switch is on.""" @@ -103,11 +94,6 @@ def is_on(self) -> bool: self.type == SWITCH_TYPE_SCHEDULE and self._schedule_state == STATE_ON ) - @property - def entity_category(self) -> EntityCategory: - """Device entity category.""" - return EntityCategory.CONFIG - def turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" if self.type == SWITCH_TYPE_SCHEDULE: diff --git a/homeassistant/components/neato/vacuum.py b/homeassistant/components/neato/vacuum.py index ecc39e515c25e9..891b090d5d360e 100644 --- a/homeassistant/components/neato/vacuum.py +++ b/homeassistant/components/neato/vacuum.py @@ -124,7 +124,6 @@ def __init__( self._robot_serial: str = self.robot.serial self._attr_unique_id: str = self.robot.serial self._status_state: str | None = None - self._clean_state: str | None = None self._state: dict[str, Any] | None = None self._clean_time_start: str | None = None self._clean_time_stop: str | None = None @@ -169,23 +168,23 @@ def update(self) -> None: robot_alert = None if self._state["state"] == 1: if self._state["details"]["isCharging"]: - self._clean_state = STATE_DOCKED + self._attr_state = STATE_DOCKED self._status_state = "Charging" elif ( self._state["details"]["isDocked"] and not self._state["details"]["isCharging"] ): - self._clean_state = STATE_DOCKED + self._attr_state = STATE_DOCKED self._status_state = "Docked" else: - self._clean_state = STATE_IDLE + self._attr_state = STATE_IDLE self._status_state = "Stopped" if robot_alert is not None: self._status_state = robot_alert elif self._state["state"] == 2: if robot_alert is None: - self._clean_state = STATE_CLEANING + self._attr_state = STATE_CLEANING self._status_state = ( f"{MODE.get(self._state['cleaning']['mode'])} " f"{ACTION.get(self._state['action'])}" @@ -200,10 +199,10 @@ def update(self) -> None: else: self._status_state = robot_alert elif self._state["state"] == 3: - self._clean_state = STATE_PAUSED + self._attr_state = STATE_PAUSED self._status_state = "Paused" elif self._state["state"] == 4: - self._clean_state = STATE_ERROR + self._attr_state = STATE_ERROR self._status_state = ERRORS.get(self._state["error"]) self._attr_battery_level = self._state["details"]["charge"] @@ -261,11 +260,6 @@ def update(self) -> None: self._robot_boundaries, ) - @property - def state(self) -> str | None: - """Return the status of the vacuum cleaner.""" - return self._clean_state - @property def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes of the vacuum cleaner.""" @@ -299,7 +293,7 @@ def extra_state_attributes(self) -> dict[str, Any]: @property def device_info(self) -> DeviceInfo: """Device info for neato robot.""" - device_info = super().device_info + device_info = self._attr_device_info if self._robot_stats: device_info["manufacturer"] = self._robot_stats["battery"]["vendor"] device_info["model"] = self._robot_stats["model"] @@ -331,9 +325,9 @@ def pause(self) -> None: def return_to_base(self, **kwargs: Any) -> None: """Set the vacuum cleaner to return to the dock.""" try: - if self._clean_state == STATE_CLEANING: + if self._attr_state == STATE_CLEANING: self.robot.pause_cleaning() - self._clean_state = STATE_RETURNING + self._attr_state = STATE_RETURNING self.robot.send_to_base() except NeatoRobotException as ex: _LOGGER.error( @@ -383,7 +377,7 @@ def neato_custom_cleaning( return _LOGGER.info("Start cleaning zone '%s' with robot %s", zone, self.entity_id) - self._clean_state = STATE_CLEANING + self._attr_state = STATE_CLEANING try: self.robot.start_cleaning(mode, navigation, category, boundary_id) except NeatoRobotException as ex: diff --git a/homeassistant/components/nest/camera.py b/homeassistant/components/nest/camera.py index 90c4056161e530..c943ea922e9e30 100644 --- a/homeassistant/components/nest/camera.py +++ b/homeassistant/components/nest/camera.py @@ -24,7 +24,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.util.dt import utcnow @@ -68,7 +67,10 @@ def __init__(self, device: Device) -> None: """Initialize the camera.""" super().__init__() self._device = device - self._device_info = NestDeviceInfo(device) + nest_device_info = NestDeviceInfo(device) + self._attr_device_info = nest_device_info.device_info + self._attr_brand = nest_device_info.device_brand + self._attr_model = nest_device_info.device_model self._stream: RtspStream | None = None self._create_stream_url_lock = asyncio.Lock() self._stream_refresh_unsub: Callable[[], None] | None = None @@ -84,33 +86,14 @@ def __init__(self, device: Device) -> None: if StreamingProtocol.RTSP in trait.supported_protocols: self._rtsp_live_stream_trait = trait self.stream_options[CONF_EXTRA_PART_WAIT_TIME] = 3 + # The API "name" field is a unique device identifier. + self._attr_unique_id = f"{self._device.name}-camera" @property def use_stream_for_stills(self) -> bool: """Whether or not to use stream to generate stills.""" return self._rtsp_live_stream_trait is not None - @property - def unique_id(self) -> str: - """Return a unique ID.""" - # The API "name" field is a unique device identifier. - return f"{self._device.name}-camera" - - @property - def device_info(self) -> DeviceInfo: - """Return device specific attributes.""" - return self._device_info.device_info - - @property - def brand(self) -> str | None: - """Return the camera brand.""" - return self._device_info.device_brand - - @property - def model(self) -> str | None: - """Return the camera model.""" - return self._device_info.device_model - @property def frontend_stream_type(self) -> StreamType | None: """Return the type of stream supported by this camera.""" diff --git a/homeassistant/components/nest/device_info.py b/homeassistant/components/nest/device_info.py index 35e32ccf1bc98f..f269e3e89d648d 100644 --- a/homeassistant/components/nest/device_info.py +++ b/homeassistant/components/nest/device_info.py @@ -66,10 +66,7 @@ def device_name(self) -> str | None: @property def device_model(self) -> str | None: """Return device model information.""" - # The API intentionally returns minimal information about specific - # devices, instead relying on traits, but we can infer a generic model - # name based on the type - return DEVICE_TYPE_MAP.get(self._device.type or "", None) + return DEVICE_TYPE_MAP.get(self._device.type) if self._device.type else None @property def suggested_area(self) -> str | None: diff --git a/homeassistant/components/nest/services.yaml b/homeassistant/components/nest/services.yaml deleted file mode 100644 index 5f68bd6a1f2db6..00000000000000 --- a/homeassistant/components/nest/services.yaml +++ /dev/null @@ -1,46 +0,0 @@ -# Describes the format for available Nest services - -set_away_mode: - fields: - away_mode: - required: true - selector: - select: - options: - - "away" - - "home" - structure: - example: "Apartment" - selector: - object: - -set_eta: - fields: - eta: - required: true - selector: - time: - eta_window: - default: "00:01" - selector: - time: - trip_id: - example: "Leave Work" - selector: - text: - structure: - example: "Apartment" - selector: - object: - -cancel_eta: - fields: - trip_id: - required: true - example: "Leave Work" - selector: - text: - structure: - example: "Apartment" - selector: - object: diff --git a/homeassistant/components/nest/strings.json b/homeassistant/components/nest/strings.json index 2c2def6b7a38d0..717ce5075f7794 100644 --- a/homeassistant/components/nest/strings.json +++ b/homeassistant/components/nest/strings.json @@ -68,57 +68,5 @@ "title": "Legacy Works With Nest has been removed", "description": "Legacy Works With Nest has been removed from Home Assistant, and the API shuts down as of September 2023.\n\nYou must take action to use the SDM API. Remove all `nest` configuration from `configuration.yaml` and restart Home Assistant, then see the Nest [integration instructions]({documentation_url}) for set up instructions and supported devices." } - }, - "services": { - "set_away_mode": { - "name": "Set away mode", - "description": "Sets the away mode for a Nest structure.", - "fields": { - "away_mode": { - "name": "Away mode", - "description": "New mode to set." - }, - "structure": { - "name": "Structure", - "description": "Name(s) of structure(s) to change. Defaults to all structures if not specified." - } - } - }, - "set_eta": { - "name": "Set estimated time of arrival", - "description": "Sets or update the estimated time of arrival window for a Nest structure.", - "fields": { - "eta": { - "name": "ETA", - "description": "Estimated time of arrival from now." - }, - "eta_window": { - "name": "ETA window", - "description": "Estimated time of arrival window." - }, - "trip_id": { - "name": "Trip ID", - "description": "Unique ID for the trip. Default is auto-generated using a timestamp." - }, - "structure": { - "name": "[%key:component::nest::services::set_away_mode::fields::structure::name%]", - "description": "[%key:component::nest::services::set_away_mode::fields::structure::description%]" - } - } - }, - "cancel_eta": { - "name": "Cancel ETA", - "description": "Cancels an existing estimated time of arrival window for a Nest structure.", - "fields": { - "trip_id": { - "name": "[%key:component::nest::services::set_eta::fields::trip_id::name%]", - "description": "Unique ID for the trip." - }, - "structure": { - "name": "[%key:component::nest::services::set_away_mode::fields::structure::name%]", - "description": "[%key:component::nest::services::set_away_mode::fields::structure::description%]" - } - } - } } } diff --git a/homeassistant/components/netatmo/cover.py b/homeassistant/components/netatmo/cover.py index 41bf84c8334788..2e4bf9e7d3c86a 100644 --- a/homeassistant/components/netatmo/cover.py +++ b/homeassistant/components/netatmo/cover.py @@ -51,6 +51,7 @@ class NetatmoCover(NetatmoBase, CoverEntity): | CoverEntityFeature.STOP | CoverEntityFeature.SET_POSITION ) + _attr_device_class = CoverDeviceClass.SHUTTER def __init__(self, netatmo_device: NetatmoDevice) -> None: """Initialize the Netatmo device.""" @@ -98,11 +99,6 @@ async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover shutter to a specific position.""" await self._cover.async_set_target_position(kwargs[ATTR_POSITION]) - @property - def device_class(self) -> CoverDeviceClass: - """Return the device class.""" - return CoverDeviceClass.SHUTTER - @callback def async_update_callback(self) -> None: """Update the entity's state.""" diff --git a/homeassistant/components/netgear/__init__.py b/homeassistant/components/netgear/__init__.py index 522b60749d004a..b21286ff05be43 100644 --- a/homeassistant/components/netgear/__init__.py +++ b/homeassistant/components/netgear/__init__.py @@ -6,7 +6,7 @@ from typing import Any from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PORT, CONF_SSL +from homeassistant.const import CONF_PORT, CONF_SSL from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -62,23 +62,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.async_on_unload(entry.add_update_listener(update_listener)) - configuration_url = None - if host := entry.data[CONF_HOST]: - configuration_url = f"http://{host}/" - - assert entry.unique_id - device_registry = dr.async_get(hass) - device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - identifiers={(DOMAIN, entry.unique_id)}, - manufacturer="Netgear", - name=router.device_name, - model=router.model, - sw_version=router.firmware_version, - hw_version=router.hardware_version, - configuration_url=configuration_url, - ) - async def async_update_devices() -> bool: """Fetch data from the router.""" if router.track_devices: diff --git a/homeassistant/components/netgear/button.py b/homeassistant/components/netgear/button.py index e45e0582d69520..f3283f8d7b53f4 100644 --- a/homeassistant/components/netgear/button.py +++ b/homeassistant/components/netgear/button.py @@ -15,7 +15,8 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN, KEY_COORDINATOR, KEY_ROUTER -from .router import NetgearRouter, NetgearRouterCoordinatorEntity +from .entity import NetgearRouterCoordinatorEntity +from .router import NetgearRouter @dataclass @@ -35,7 +36,6 @@ class NetgearButtonEntityDescription( BUTTONS = [ NetgearButtonEntityDescription( key="reboot", - name="Reboot", device_class=ButtonDeviceClass.RESTART, entity_category=EntityCategory.CONFIG, action=lambda router: router.async_reboot, @@ -69,8 +69,7 @@ def __init__( """Initialize a Netgear device.""" super().__init__(coordinator, router) self.entity_description = entity_description - self._name = f"{router.device_name} {entity_description.name}" - self._unique_id = f"{router.serial_number}-{entity_description.key}" + self._attr_unique_id = f"{router.serial_number}-{entity_description.key}" async def async_press(self) -> None: """Triggers the button press service.""" diff --git a/homeassistant/components/netgear/config_flow.py b/homeassistant/components/netgear/config_flow.py index da260a2559ed96..7b74880d011dbf 100644 --- a/homeassistant/components/netgear/config_flow.py +++ b/homeassistant/components/netgear/config_flow.py @@ -190,8 +190,6 @@ async def async_step_user(self, user_input=None): ) except CannotLoginException: errors["base"] = "config" - - if errors: return await self._show_setup_form(user_input, errors) config_data = { @@ -204,6 +202,10 @@ async def async_step_user(self, user_input=None): # Check if already configured info = await self.hass.async_add_executor_job(api.get_info) + if info is None: + errors["base"] = "info" + return await self._show_setup_form(user_input, errors) + await self.async_set_unique_id(info["SerialNumber"], raise_on_progress=False) self._abort_if_unique_id_configured(updates=config_data) diff --git a/homeassistant/components/netgear/device_tracker.py b/homeassistant/components/netgear/device_tracker.py index ffb33d5ebeb0fd..38ad024a2c4090 100644 --- a/homeassistant/components/netgear/device_tracker.py +++ b/homeassistant/components/netgear/device_tracker.py @@ -10,7 +10,8 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DEVICE_ICONS, DOMAIN, KEY_COORDINATOR, KEY_ROUTER -from .router import NetgearBaseEntity, NetgearRouter +from .entity import NetgearDeviceEntity +from .router import NetgearRouter _LOGGER = logging.getLogger(__name__) @@ -46,9 +47,11 @@ def new_device_callback() -> None: new_device_callback() -class NetgearScannerEntity(NetgearBaseEntity, ScannerEntity): +class NetgearScannerEntity(NetgearDeviceEntity, ScannerEntity): """Representation of a device connected to a Netgear router.""" + _attr_has_entity_name = False + def __init__( self, coordinator: DataUpdateCoordinator, router: NetgearRouter, device: dict ) -> None: @@ -56,6 +59,7 @@ def __init__( super().__init__(coordinator, router, device) self._hostname = self.get_hostname() self._icon = DEVICE_ICONS.get(device["device_type"], "mdi:help-network") + self._attr_name = self._device_name def get_hostname(self) -> str | None: """Return the hostname of the given device or None if we don't know.""" diff --git a/homeassistant/components/netgear/entity.py b/homeassistant/components/netgear/entity.py new file mode 100644 index 00000000000000..45418681db0cb8 --- /dev/null +++ b/homeassistant/components/netgear/entity.py @@ -0,0 +1,107 @@ +"""Represent the Netgear router and its devices.""" +from __future__ import annotations + +from abc import abstractmethod + +from homeassistant.const import CONF_HOST +from homeassistant.core import callback +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from .const import DOMAIN +from .router import NetgearRouter + + +class NetgearDeviceEntity(CoordinatorEntity): + """Base class for a device connected to a Netgear router.""" + + _attr_has_entity_name = True + + def __init__( + self, coordinator: DataUpdateCoordinator, router: NetgearRouter, device: dict + ) -> None: + """Initialize a Netgear device.""" + super().__init__(coordinator) + self._router = router + self._device = device + self._mac = device["mac"] + self._device_name = self.get_device_name() + self._active = device["active"] + self._attr_unique_id = self._mac + self._attr_device_info = DeviceInfo( + connections={(dr.CONNECTION_NETWORK_MAC, self._mac)}, + default_name=self._device_name, + default_model=device["device_model"], + via_device=(DOMAIN, router.unique_id), + ) + + def get_device_name(self): + """Return the name of the given device or the MAC if we don't know.""" + name = self._device["name"] + if not name or name == "--": + name = self._mac + + return name + + @abstractmethod + @callback + def async_update_device(self) -> None: + """Update the Netgear device.""" + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self.async_update_device() + super()._handle_coordinator_update() + + +class NetgearRouterEntity(Entity): + """Base class for a Netgear router entity without coordinator.""" + + _attr_has_entity_name = True + + def __init__(self, router: NetgearRouter) -> None: + """Initialize a Netgear device.""" + self._router = router + + configuration_url = None + if host := router.entry.data[CONF_HOST]: + configuration_url = f"http://{host}/" + + self._attr_unique_id = router.serial_number + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, router.unique_id)}, + manufacturer="Netgear", + name=router.device_name, + model=router.model, + sw_version=router.firmware_version, + hw_version=router.hardware_version, + configuration_url=configuration_url, + ) + + +class NetgearRouterCoordinatorEntity(NetgearRouterEntity, CoordinatorEntity): + """Base class for a Netgear router entity.""" + + def __init__( + self, coordinator: DataUpdateCoordinator, router: NetgearRouter + ) -> None: + """Initialize a Netgear device.""" + CoordinatorEntity.__init__(self, coordinator) + NetgearRouterEntity.__init__(self, router) + + @abstractmethod + @callback + def async_update_device(self) -> None: + """Update the Netgear device.""" + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self.async_update_device() + super()._handle_coordinator_update() diff --git a/homeassistant/components/netgear/manifest.json b/homeassistant/components/netgear/manifest.json index be4dd0f2d9db5e..59a41542d7cf8e 100644 --- a/homeassistant/components/netgear/manifest.json +++ b/homeassistant/components/netgear/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/netgear", "iot_class": "local_polling", "loggers": ["pynetgear"], - "requirements": ["pynetgear==0.10.9"], + "requirements": ["pynetgear==0.10.10"], "ssdp": [ { "manufacturer": "NETGEAR, Inc.", diff --git a/homeassistant/components/netgear/router.py b/homeassistant/components/netgear/router.py index 2dc86833003097..3c3be7fe9fbbb4 100644 --- a/homeassistant/components/netgear/router.py +++ b/homeassistant/components/netgear/router.py @@ -1,7 +1,6 @@ """Represent the Netgear router and its devices.""" from __future__ import annotations -from abc import abstractmethod import asyncio from datetime import timedelta import logging @@ -17,14 +16,8 @@ CONF_SSL, CONF_USERNAME, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity import Entity -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) from homeassistant.util import dt as dt_util from .const import ( @@ -275,137 +268,3 @@ def port(self) -> int: def ssl(self) -> bool: """SSL used by the API.""" return self.api.ssl - - -class NetgearBaseEntity(CoordinatorEntity): - """Base class for a device connected to a Netgear router.""" - - def __init__( - self, coordinator: DataUpdateCoordinator, router: NetgearRouter, device: dict - ) -> None: - """Initialize a Netgear device.""" - super().__init__(coordinator) - self._router = router - self._device = device - self._mac = device["mac"] - self._name = self.get_device_name() - self._device_name = self._name - self._active = device["active"] - - def get_device_name(self): - """Return the name of the given device or the MAC if we don't know.""" - name = self._device["name"] - if not name or name == "--": - name = self._mac - - return name - - @abstractmethod - @callback - def async_update_device(self) -> None: - """Update the Netgear device.""" - - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" - self.async_update_device() - super()._handle_coordinator_update() - - @property - def name(self) -> str: - """Return the name.""" - return self._name - - -class NetgearDeviceEntity(NetgearBaseEntity): - """Base class for a device connected to a Netgear router.""" - - def __init__( - self, coordinator: DataUpdateCoordinator, router: NetgearRouter, device: dict - ) -> None: - """Initialize a Netgear device.""" - super().__init__(coordinator, router, device) - self._unique_id = self._mac - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return self._unique_id - - @property - def device_info(self) -> DeviceInfo: - """Return the device information.""" - return DeviceInfo( - connections={(dr.CONNECTION_NETWORK_MAC, self._mac)}, - default_name=self._device_name, - default_model=self._device["device_model"], - via_device=(DOMAIN, self._router.unique_id), - ) - - -class NetgearRouterCoordinatorEntity(CoordinatorEntity): - """Base class for a Netgear router entity.""" - - def __init__( - self, coordinator: DataUpdateCoordinator, router: NetgearRouter - ) -> None: - """Initialize a Netgear device.""" - super().__init__(coordinator) - self._router = router - self._name = router.device_name - self._unique_id = router.serial_number - - @abstractmethod - @callback - def async_update_device(self) -> None: - """Update the Netgear device.""" - - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" - self.async_update_device() - super()._handle_coordinator_update() - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return self._unique_id - - @property - def name(self) -> str: - """Return the name.""" - return self._name - - @property - def device_info(self) -> DeviceInfo: - """Return the device information.""" - return DeviceInfo( - identifiers={(DOMAIN, self._router.unique_id)}, - ) - - -class NetgearRouterEntity(Entity): - """Base class for a Netgear router entity without coordinator.""" - - def __init__(self, router: NetgearRouter) -> None: - """Initialize a Netgear device.""" - self._router = router - self._name = router.device_name - self._unique_id = router.serial_number - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return self._unique_id - - @property - def name(self) -> str: - """Return the name.""" - return self._name - - @property - def device_info(self) -> DeviceInfo: - """Return the device information.""" - return DeviceInfo( - identifiers={(DOMAIN, self._router.unique_id)}, - ) diff --git a/homeassistant/components/netgear/sensor.py b/homeassistant/components/netgear/sensor.py index 239eca5ff839c8..6e7771d44cb968 100644 --- a/homeassistant/components/netgear/sensor.py +++ b/homeassistant/components/netgear/sensor.py @@ -36,40 +36,41 @@ KEY_COORDINATOR_UTIL, KEY_ROUTER, ) -from .router import NetgearDeviceEntity, NetgearRouter, NetgearRouterCoordinatorEntity +from .entity import NetgearDeviceEntity, NetgearRouterCoordinatorEntity +from .router import NetgearRouter _LOGGER = logging.getLogger(__name__) SENSOR_TYPES = { "type": SensorEntityDescription( key="type", - name="link type", + translation_key="link_type", entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:lan", ), "link_rate": SensorEntityDescription( key="link_rate", - name="link rate", + translation_key="link_rate", native_unit_of_measurement="Mbps", entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:speedometer", ), "signal": SensorEntityDescription( key="signal", - name="signal strength", + translation_key="signal_strength", native_unit_of_measurement=PERCENTAGE, entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:wifi", ), "ssid": SensorEntityDescription( key="ssid", - name="ssid", + translation_key="ssid", entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:wifi-marker", ), "conn_ap_mac": SensorEntityDescription( key="conn_ap_mac", - name="access point mac", + translation_key="access_point_mac", entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:router-network", ), @@ -87,7 +88,7 @@ class NetgearSensorEntityDescription(SensorEntityDescription): SENSOR_TRAFFIC_TYPES = [ NetgearSensorEntityDescription( key="NewTodayUpload", - name="Upload today", + translation_key="upload_today", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, @@ -95,7 +96,7 @@ class NetgearSensorEntityDescription(SensorEntityDescription): ), NetgearSensorEntityDescription( key="NewTodayDownload", - name="Download today", + translation_key="download_today", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, @@ -103,7 +104,7 @@ class NetgearSensorEntityDescription(SensorEntityDescription): ), NetgearSensorEntityDescription( key="NewYesterdayUpload", - name="Upload yesterday", + translation_key="upload_yesterday", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, @@ -111,7 +112,7 @@ class NetgearSensorEntityDescription(SensorEntityDescription): ), NetgearSensorEntityDescription( key="NewYesterdayDownload", - name="Download yesterday", + translation_key="download_yesterday", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, @@ -119,7 +120,7 @@ class NetgearSensorEntityDescription(SensorEntityDescription): ), NetgearSensorEntityDescription( key="NewWeekUpload", - name="Upload week", + translation_key="upload_week", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, @@ -129,7 +130,7 @@ class NetgearSensorEntityDescription(SensorEntityDescription): ), NetgearSensorEntityDescription( key="NewWeekUpload", - name="Upload week average", + translation_key="upload_week_average", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, @@ -139,7 +140,7 @@ class NetgearSensorEntityDescription(SensorEntityDescription): ), NetgearSensorEntityDescription( key="NewWeekDownload", - name="Download week", + translation_key="download_week", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, @@ -149,7 +150,7 @@ class NetgearSensorEntityDescription(SensorEntityDescription): ), NetgearSensorEntityDescription( key="NewWeekDownload", - name="Download week average", + translation_key="download_week_average", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, @@ -159,7 +160,7 @@ class NetgearSensorEntityDescription(SensorEntityDescription): ), NetgearSensorEntityDescription( key="NewMonthUpload", - name="Upload month", + translation_key="upload_month", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, @@ -169,7 +170,7 @@ class NetgearSensorEntityDescription(SensorEntityDescription): ), NetgearSensorEntityDescription( key="NewMonthUpload", - name="Upload month average", + translation_key="upload_month_average", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, @@ -179,7 +180,7 @@ class NetgearSensorEntityDescription(SensorEntityDescription): ), NetgearSensorEntityDescription( key="NewMonthDownload", - name="Download month", + translation_key="download_month", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, @@ -189,7 +190,7 @@ class NetgearSensorEntityDescription(SensorEntityDescription): ), NetgearSensorEntityDescription( key="NewMonthDownload", - name="Download month average", + translation_key="download_month_average", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, @@ -199,7 +200,7 @@ class NetgearSensorEntityDescription(SensorEntityDescription): ), NetgearSensorEntityDescription( key="NewLastMonthUpload", - name="Upload last month", + translation_key="upload_last_month", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, @@ -209,7 +210,7 @@ class NetgearSensorEntityDescription(SensorEntityDescription): ), NetgearSensorEntityDescription( key="NewLastMonthUpload", - name="Upload last month average", + translation_key="upload_last_month_average", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, @@ -219,7 +220,7 @@ class NetgearSensorEntityDescription(SensorEntityDescription): ), NetgearSensorEntityDescription( key="NewLastMonthDownload", - name="Download last month", + translation_key="download_last_month", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, @@ -229,7 +230,7 @@ class NetgearSensorEntityDescription(SensorEntityDescription): ), NetgearSensorEntityDescription( key="NewLastMonthDownload", - name="Download last month average", + translation_key="download_last_month_average", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, @@ -242,7 +243,7 @@ class NetgearSensorEntityDescription(SensorEntityDescription): SENSOR_SPEED_TYPES = [ NetgearSensorEntityDescription( key="NewOOKLAUplinkBandwidth", - name="Uplink Bandwidth", + translation_key="uplink_bandwidth", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, @@ -250,7 +251,7 @@ class NetgearSensorEntityDescription(SensorEntityDescription): ), NetgearSensorEntityDescription( key="NewOOKLADownlinkBandwidth", - name="Downlink Bandwidth", + translation_key="downlink_bandwidth", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, @@ -258,7 +259,7 @@ class NetgearSensorEntityDescription(SensorEntityDescription): ), NetgearSensorEntityDescription( key="AveragePing", - name="Average Ping", + translation_key="average_ping", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfTime.MILLISECONDS, icon="mdi:wan", @@ -268,7 +269,7 @@ class NetgearSensorEntityDescription(SensorEntityDescription): SENSOR_UTILIZATION = [ NetgearSensorEntityDescription( key="NewCPUUtilization", - name="CPU Utilization", + translation_key="cpu_utilization", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=PERCENTAGE, icon="mdi:cpu-64-bit", @@ -276,7 +277,7 @@ class NetgearSensorEntityDescription(SensorEntityDescription): ), NetgearSensorEntityDescription( key="NewMemoryUtilization", - name="Memory Utilization", + translation_key="memory_utilization", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=PERCENTAGE, icon="mdi:memory", @@ -287,7 +288,7 @@ class NetgearSensorEntityDescription(SensorEntityDescription): SENSOR_LINK_TYPES = [ NetgearSensorEntityDescription( key="NewEthernetLinkStatus", - name="Ethernet Link Status", + translation_key="ethernet_link_status", entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:ethernet", ), @@ -379,10 +380,9 @@ def __init__( """Initialize a Netgear device.""" super().__init__(coordinator, router, device) self._attribute = attribute - self.entity_description = SENSOR_TYPES[self._attribute] - self._name = f"{self.get_device_name()} {self.entity_description.name}" - self._unique_id = f"{self._mac}-{self._attribute}" - self._state = self._device.get(self._attribute) + self.entity_description = SENSOR_TYPES[attribute] + self._attr_unique_id = f"{self._mac}-{attribute}" + self._state = device.get(attribute) @property def native_value(self): @@ -413,8 +413,7 @@ def __init__( """Initialize a Netgear device.""" super().__init__(coordinator, router) self.entity_description = entity_description - self._name = f"{router.device_name} {entity_description.name}" - self._unique_id = f"{router.serial_number}-{entity_description.key}-{entity_description.index}" + self._attr_unique_id = f"{router.serial_number}-{entity_description.key}-{entity_description.index}" self._value: StateType | date | datetime | Decimal = None self.async_update_device() diff --git a/homeassistant/components/netgear/strings.json b/homeassistant/components/netgear/strings.json index 7941d1fe0a7c6b..6b4883b8ce31fc 100644 --- a/homeassistant/components/netgear/strings.json +++ b/homeassistant/components/netgear/strings.json @@ -4,14 +4,15 @@ "user": { "description": "Default host: {host}\nDefault username: {username}", "data": { - "host": "Host (Optional)", - "username": "Username (Optional)", + "host": "[%key:common::config_flow::data::host%]", + "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" } } }, "error": { - "config": "Connection or login error: please check your configuration" + "config": "Connection or login error: please check your configuration", + "info": "Failed to get info from router" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", @@ -28,5 +29,116 @@ } } } + }, + "entity": { + "sensor": { + "link_type": { + "name": "Link type" + }, + "link_rate": { + "name": "Link rate" + }, + "signal_strength": { + "name": "[%key:component::sensor::entity_component::signal_strength::name%]" + }, + "ssid": { + "name": "SSID" + }, + "access_point_mac": { + "name": "Access point mac" + }, + "upload_today": { + "name": "Upload today" + }, + "download_today": { + "name": "Download today" + }, + "upload_yesterday": { + "name": "Upload yesterday" + }, + "download_yesterday": { + "name": "Download yesterday" + }, + "upload_week": { + "name": "Upload this week" + }, + "upload_week_average": { + "name": "Upload this week average" + }, + "download_week": { + "name": "Download this week" + }, + "download_week_average": { + "name": "Download this week average" + }, + "upload_month": { + "name": "Upload this month" + }, + "upload_month_average": { + "name": "Upload this month average" + }, + "download_month": { + "name": "Download this month" + }, + "download_month_average": { + "name": "Download this month average" + }, + "upload_last_month": { + "name": "Upload last month" + }, + "upload_last_month_average": { + "name": "Upload last month average" + }, + "download_last_month": { + "name": "Download last month" + }, + "download_last_month_average": { + "name": "Download last month average" + }, + "uplink_bandwidth": { + "name": "Uplink bandwidth" + }, + "downlink_bandwidth": { + "name": "Downlink bandwidth" + }, + "average_ping": { + "name": "Average ping" + }, + "cpu_utilization": { + "name": "CPU utilization" + }, + "memory_utilization": { + "name": "Memory utilization" + }, + "ethernet_link_status": { + "name": "Ethernet link status" + } + }, + "switch": { + "allowed_on_network": { + "name": "Allowed on network" + }, + "access_control": { + "name": "Access control" + }, + "traffic_meter": { + "name": "Traffic meter" + }, + "parental_control": { + "name": "Parental control" + }, + "quality_of_service": { + "name": "Quality of service" + }, + "2g_guest_wifi": { + "name": "2.4GHz guest Wi-Fi" + }, + "5g_guest_wifi": { + "name": "5GHz guest Wi-Fi" + }, + "smart_connect": { + "name": "Smart connect" + } + } } } diff --git a/homeassistant/components/netgear/switch.py b/homeassistant/components/netgear/switch.py index 88a89dd32c953e..a4548da16a4c56 100644 --- a/homeassistant/components/netgear/switch.py +++ b/homeassistant/components/netgear/switch.py @@ -15,7 +15,8 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN, KEY_COORDINATOR, KEY_ROUTER -from .router import NetgearDeviceEntity, NetgearRouter, NetgearRouterEntity +from .entity import NetgearDeviceEntity, NetgearRouterEntity +from .router import NetgearRouter _LOGGER = logging.getLogger(__name__) @@ -24,7 +25,7 @@ SWITCH_TYPES = [ SwitchEntityDescription( key="allow_or_block", - name="Allowed on network", + translation_key="allowed_on_network", icon="mdi:block-helper", entity_category=EntityCategory.CONFIG, ) @@ -49,7 +50,7 @@ class NetgearSwitchEntityDescription( ROUTER_SWITCH_TYPES = [ NetgearSwitchEntityDescription( key="access_control", - name="Access Control", + translation_key="access_control", icon="mdi:block-helper", entity_category=EntityCategory.CONFIG, update=lambda router: router.api.get_block_device_enable_status, @@ -57,7 +58,7 @@ class NetgearSwitchEntityDescription( ), NetgearSwitchEntityDescription( key="traffic_meter", - name="Traffic Meter", + translation_key="traffic_meter", icon="mdi:wifi-arrow-up-down", entity_category=EntityCategory.CONFIG, update=lambda router: router.api.get_traffic_meter_enabled, @@ -65,7 +66,7 @@ class NetgearSwitchEntityDescription( ), NetgearSwitchEntityDescription( key="parental_control", - name="Parental Control", + translation_key="parental_control", icon="mdi:account-child-outline", entity_category=EntityCategory.CONFIG, update=lambda router: router.api.get_parental_control_enable_status, @@ -73,7 +74,7 @@ class NetgearSwitchEntityDescription( ), NetgearSwitchEntityDescription( key="qos", - name="Quality of Service", + translation_key="quality_of_service", icon="mdi:wifi-star", entity_category=EntityCategory.CONFIG, update=lambda router: router.api.get_qos_enable_status, @@ -81,7 +82,7 @@ class NetgearSwitchEntityDescription( ), NetgearSwitchEntityDescription( key="2g_guest_wifi", - name="2.4G Guest Wifi", + translation_key="2g_guest_wifi", icon="mdi:wifi", entity_category=EntityCategory.CONFIG, update=lambda router: router.api.get_2g_guest_access_enabled, @@ -89,7 +90,7 @@ class NetgearSwitchEntityDescription( ), NetgearSwitchEntityDescription( key="5g_guest_wifi", - name="5G Guest Wifi", + translation_key="5g_guest_wifi", icon="mdi:wifi", entity_category=EntityCategory.CONFIG, update=lambda router: router.api.get_5g_guest_access_enabled, @@ -97,7 +98,7 @@ class NetgearSwitchEntityDescription( ), NetgearSwitchEntityDescription( key="smart_connect", - name="Smart Connect", + translation_key="smart_connect", icon="mdi:wifi", entity_category=EntityCategory.CONFIG, update=lambda router: router.api.get_smart_connect_enabled, @@ -166,9 +167,7 @@ def __init__( """Initialize a Netgear device.""" super().__init__(coordinator, router, device) self.entity_description = entity_description - self._name = f"{self.get_device_name()} {self.entity_description.name}" - self._unique_id = f"{self._mac}-{self.entity_description.key}" - self._attr_is_on = None + self._attr_unique_id = f"{self._mac}-{entity_description.key}" self.async_update_device() async def async_turn_on(self, **kwargs: Any) -> None: @@ -206,8 +205,7 @@ def __init__( """Initialize a Netgear device.""" super().__init__(router) self.entity_description = entity_description - self._name = f"{router.device_name} {entity_description.name}" - self._unique_id = f"{router.serial_number}-{entity_description.key}" + self._attr_unique_id = f"{router.serial_number}-{entity_description.key}" self._attr_is_on = None self._attr_available = False diff --git a/homeassistant/components/netgear/update.py b/homeassistant/components/netgear/update.py index b0e9a26864b227..78e11e7c1743b5 100644 --- a/homeassistant/components/netgear/update.py +++ b/homeassistant/components/netgear/update.py @@ -15,7 +15,8 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN, KEY_COORDINATOR_FIRMWARE, KEY_ROUTER -from .router import NetgearRouter, NetgearRouterCoordinatorEntity +from .entity import NetgearRouterCoordinatorEntity +from .router import NetgearRouter LOGGER = logging.getLogger(__name__) @@ -44,8 +45,7 @@ def __init__( ) -> None: """Initialize a Netgear device.""" super().__init__(coordinator, router) - self._name = f"{router.device_name} Update" - self._unique_id = f"{router.serial_number}-update" + self._attr_unique_id = f"{router.serial_number}-update" @property def installed_version(self) -> str | None: diff --git a/homeassistant/components/nextbus/__init__.py b/homeassistant/components/nextbus/__init__.py index 4891af77b281b6..b582f82b929e5b 100644 --- a/homeassistant/components/nextbus/__init__.py +++ b/homeassistant/components/nextbus/__init__.py @@ -1 +1,18 @@ -"""NextBus sensor.""" +"""NextBus platform.""" + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +PLATFORMS = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up platforms for NextBus.""" + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/nextbus/config_flow.py b/homeassistant/components/nextbus/config_flow.py new file mode 100644 index 00000000000000..d7149bcc9f4657 --- /dev/null +++ b/homeassistant/components/nextbus/config_flow.py @@ -0,0 +1,236 @@ +"""Config flow to configure the Nextbus integration.""" +from collections import Counter +import logging + +from py_nextbus import NextBusClient +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_NAME +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) + +from .const import CONF_AGENCY, CONF_ROUTE, CONF_STOP, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +def _dict_to_select_selector(options: dict[str, str]) -> SelectSelector: + return SelectSelector( + SelectSelectorConfig( + options=sorted( + ( + SelectOptionDict(value=key, label=value) + for key, value in options.items() + ), + key=lambda o: o["label"], + ), + mode=SelectSelectorMode.DROPDOWN, + ) + ) + + +def _get_agency_tags(client: NextBusClient) -> dict[str, str]: + return {a["tag"]: a["title"] for a in client.get_agency_list()["agency"]} + + +def _get_route_tags(client: NextBusClient, agency_tag: str) -> dict[str, str]: + return {a["tag"]: a["title"] for a in client.get_route_list(agency_tag)["route"]} + + +def _get_stop_tags( + client: NextBusClient, agency_tag: str, route_tag: str +) -> dict[str, str]: + route_config = client.get_route_config(route_tag, agency_tag) + tags = {a["tag"]: a["title"] for a in route_config["route"]["stop"]} + title_counts = Counter(tags.values()) + + stop_directions: dict[str, str] = {} + for direction in route_config["route"]["direction"]: + for stop in direction["stop"]: + stop_directions[stop["tag"]] = direction["name"] + + # Append directions for stops with shared titles + for tag, title in tags.items(): + if title_counts[title] > 1: + tags[tag] = f"{title} ({stop_directions[tag]})" + + return tags + + +def _validate_import( + client: NextBusClient, agency_tag: str, route_tag: str, stop_tag: str +) -> str | tuple[str, str, str]: + agency_tags = _get_agency_tags(client) + agency = agency_tags.get(agency_tag) + if not agency: + return "invalid_agency" + + route_tags = _get_route_tags(client, agency_tag) + route = route_tags.get(route_tag) + if not route: + return "invalid_route" + + stop_tags = _get_stop_tags(client, agency_tag, route_tag) + stop = stop_tags.get(stop_tag) + if not stop: + return "invalid_stop" + + return agency, route, stop + + +def _unique_id_from_data(data: dict[str, str]) -> str: + return f"{data[CONF_AGENCY]}_{data[CONF_ROUTE]}_{data[CONF_STOP]}" + + +class NextBusFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle Nextbus configuration.""" + + VERSION = 1 + + _agency_tags: dict[str, str] + _route_tags: dict[str, str] + _stop_tags: dict[str, str] + + def __init__(self): + """Initialize NextBus config flow.""" + self.data: dict[str, str] = {} + self._client = NextBusClient(output_format="json") + _LOGGER.info("Init new config flow") + + async def async_step_import(self, config_input: dict[str, str]) -> FlowResult: + """Handle import of config.""" + agency_tag = config_input[CONF_AGENCY] + route_tag = config_input[CONF_ROUTE] + stop_tag = config_input[CONF_STOP] + + validation_result = await self.hass.async_add_executor_job( + _validate_import, + self._client, + agency_tag, + route_tag, + stop_tag, + ) + if isinstance(validation_result, str): + return self.async_abort(reason=validation_result) + + data = { + CONF_AGENCY: agency_tag, + CONF_ROUTE: route_tag, + CONF_STOP: stop_tag, + CONF_NAME: config_input.get( + CONF_NAME, + f"{config_input[CONF_AGENCY]} {config_input[CONF_ROUTE]}", + ), + } + + await self.async_set_unique_id(_unique_id_from_data(data)) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=" ".join(validation_result), + data=data, + ) + + async def async_step_user( + self, + user_input: dict[str, str] | None = None, + ) -> FlowResult: + """Handle a flow initiated by the user.""" + return await self.async_step_agency(user_input) + + async def async_step_agency( + self, + user_input: dict[str, str] | None = None, + ) -> FlowResult: + """Select agency.""" + if user_input is not None: + self.data[CONF_AGENCY] = user_input[CONF_AGENCY] + + return await self.async_step_route() + + self._agency_tags = await self.hass.async_add_executor_job( + _get_agency_tags, self._client + ) + + return self.async_show_form( + step_id="agency", + data_schema=vol.Schema( + { + vol.Required(CONF_AGENCY): _dict_to_select_selector( + self._agency_tags + ), + } + ), + ) + + async def async_step_route( + self, + user_input: dict[str, str] | None = None, + ) -> FlowResult: + """Select route.""" + if user_input is not None: + self.data[CONF_ROUTE] = user_input[CONF_ROUTE] + + return await self.async_step_stop() + + self._route_tags = await self.hass.async_add_executor_job( + _get_route_tags, self._client, self.data[CONF_AGENCY] + ) + + return self.async_show_form( + step_id="route", + data_schema=vol.Schema( + { + vol.Required(CONF_ROUTE): _dict_to_select_selector( + self._route_tags + ), + } + ), + ) + + async def async_step_stop( + self, + user_input: dict[str, str] | None = None, + ) -> FlowResult: + """Select stop.""" + + if user_input is not None: + self.data[CONF_STOP] = user_input[CONF_STOP] + + await self.async_set_unique_id(_unique_id_from_data(self.data)) + self._abort_if_unique_id_configured() + + agency_tag = self.data[CONF_AGENCY] + route_tag = self.data[CONF_ROUTE] + stop_tag = self.data[CONF_STOP] + + agency_name = self._agency_tags[agency_tag] + route_name = self._route_tags[route_tag] + stop_name = self._stop_tags[stop_tag] + + return self.async_create_entry( + title=f"{agency_name} {route_name} {stop_name}", + data=self.data, + ) + + self._stop_tags = await self.hass.async_add_executor_job( + _get_stop_tags, + self._client, + self.data[CONF_AGENCY], + self.data[CONF_ROUTE], + ) + + return self.async_show_form( + step_id="stop", + data_schema=vol.Schema( + { + vol.Required(CONF_STOP): _dict_to_select_selector(self._stop_tags), + } + ), + ) diff --git a/homeassistant/components/nextbus/manifest.json b/homeassistant/components/nextbus/manifest.json index 4b8bd1a929420f..15eb9b4e2454c5 100644 --- a/homeassistant/components/nextbus/manifest.json +++ b/homeassistant/components/nextbus/manifest.json @@ -2,6 +2,7 @@ "domain": "nextbus", "name": "NextBus", "codeowners": ["@vividboarder"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/nextbus", "iot_class": "cloud_polling", "loggers": ["py_nextbus"], diff --git a/homeassistant/components/nextbus/sensor.py b/homeassistant/components/nextbus/sensor.py index b8f36e10fa1070..1582ec25ffef17 100644 --- a/homeassistant/components/nextbus/sensor.py +++ b/homeassistant/components/nextbus/sensor.py @@ -12,14 +12,16 @@ SensorDeviceClass, SensorEntity, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_NAME -from homeassistant.core import HomeAssistant +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.dt import utc_from_timestamp -from .const import CONF_AGENCY, CONF_ROUTE, CONF_STOP +from .const import CONF_AGENCY, CONF_ROUTE, CONF_STOP, DOMAIN from .util import listify, maybe_first _LOGGER = logging.getLogger(__name__) @@ -34,59 +36,54 @@ ) -def validate_value(value_name, value, value_list): - """Validate tag value is in the list of items and logs error if not.""" - valid_values = {v["tag"]: v["title"] for v in value_list} - if value not in valid_values: - _LOGGER.error( - "Invalid %s tag `%s`. Please use one of the following: %s", - value_name, - value, - ", ".join(f"{title}: {tag}" for tag, title in valid_values.items()), +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Initialize nextbus import from config.""" + async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + is_fixable=False, + breaks_in_ha_version="2024.4.0", + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "NextBus", + }, + ) + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config ) - return False - - return True - - -def validate_tags(client, agency, route, stop): - """Validate provided tags.""" - # Validate agencies - if not validate_value("agency", agency, client.get_agency_list()["agency"]): - return False - - # Validate the route - if not validate_value("route", route, client.get_route_list(agency)["route"]): - return False + ) - # Validate the stop - route_config = client.get_route_config(route, agency)["route"] - if not validate_value("stop", stop, route_config["stop"]): - return False - return True - - -def setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, + config: ConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Load values from configuration and initialize the platform.""" - agency = config[CONF_AGENCY] - route = config[CONF_ROUTE] - stop = config[CONF_STOP] - name = config.get(CONF_NAME) - client = NextBusClient(output_format="json") - # Ensures that the tags provided are valid, also logs out valid values - if not validate_tags(client, agency, route, stop): - _LOGGER.error("Invalid config value(s)") - return + _LOGGER.debug(config.data) + + sensor = NextBusDepartureSensor( + client, + config.unique_id, + config.data[CONF_AGENCY], + config.data[CONF_ROUTE], + config.data[CONF_STOP], + config.data.get(CONF_NAME) or config.title, + ) - add_entities([NextBusDepartureSensor(client, agency, route, stop, name)], True) + async_add_entities((sensor,), True) class NextBusDepartureSensor(SensorEntity): @@ -103,17 +100,14 @@ class NextBusDepartureSensor(SensorEntity): _attr_device_class = SensorDeviceClass.TIMESTAMP _attr_icon = "mdi:bus" - def __init__(self, client, agency, route, stop, name=None): + def __init__(self, client, unique_id, agency, route, stop, name): """Initialize sensor with all required config.""" self.agency = agency self.route = route self.stop = stop self._attr_extra_state_attributes = {} - - # Maybe pull a more user friendly name from the API here - self._attr_name = f"{agency} {route}" - if name: - self._attr_name = name + self._attr_unique_id = unique_id + self._attr_name = name self._client = client diff --git a/homeassistant/components/nextbus/strings.json b/homeassistant/components/nextbus/strings.json new file mode 100644 index 00000000000000..4f54ebf165652f --- /dev/null +++ b/homeassistant/components/nextbus/strings.json @@ -0,0 +1,33 @@ +{ + "title": "NextBus predictions", + "config": { + "step": { + "agency": { + "title": "Select metro agency", + "data": { + "agency": "Metro agency" + } + }, + "route": { + "title": "Select route", + "data": { + "route": "Route" + } + }, + "stop": { + "title": "Select stop", + "data": { + "stop": "Stop" + } + } + }, + "error": { + "invalid_agency": "The agency value selected is not valid", + "invalid_route": "The route value selected is not valid", + "invalid_stop": "The stop value selected is not valid" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + } + } +} diff --git a/homeassistant/components/nextbus/util.py b/homeassistant/components/nextbus/util.py index c753c452546a04..73b3b400ff412e 100644 --- a/homeassistant/components/nextbus/util.py +++ b/homeassistant/components/nextbus/util.py @@ -17,7 +17,7 @@ def listify(maybe_list: Any) -> list[Any]: return [maybe_list] -def maybe_first(maybe_list: list[Any]) -> Any: +def maybe_first(maybe_list: list[Any] | None) -> Any: """Return the first item out of a list or returns back the input.""" if isinstance(maybe_list, list) and maybe_list: return maybe_list[0] diff --git a/homeassistant/components/nibe_heatpump/number.py b/homeassistant/components/nibe_heatpump/number.py index 79078811881a8a..1b3bc928985505 100644 --- a/homeassistant/components/nibe_heatpump/number.py +++ b/homeassistant/components/nibe_heatpump/number.py @@ -56,7 +56,6 @@ def __init__(self, coordinator: Coordinator, coil: Coil) -> None: self._attr_native_step = 1 / coil.factor self._attr_native_unit_of_measurement = coil.unit - self._attr_native_value = None def _async_read_coil(self, data: CoilData) -> None: if data.value is None: diff --git a/homeassistant/components/nibe_heatpump/switch.py b/homeassistant/components/nibe_heatpump/switch.py index 95d96de9764d0a..16a7ef2b1f569f 100644 --- a/homeassistant/components/nibe_heatpump/switch.py +++ b/homeassistant/components/nibe_heatpump/switch.py @@ -38,7 +38,6 @@ class Switch(CoilEntity, SwitchEntity): def __init__(self, coordinator: Coordinator, coil: Coil) -> None: """Initialize entity.""" super().__init__(coordinator, coil, ENTITY_ID_FORMAT) - self._attr_is_on = None def _async_read_coil(self, data: CoilData) -> None: self._attr_is_on = data.value == "ON" diff --git a/homeassistant/components/nightscout/sensor.py b/homeassistant/components/nightscout/sensor.py index 795e7b17a1647a..f60c70cc67ce63 100644 --- a/homeassistant/components/nightscout/sensor.py +++ b/homeassistant/components/nightscout/sensor.py @@ -37,15 +37,15 @@ async def async_setup_entry( class NightscoutSensor(SensorEntity): """Implementation of a Nightscout sensor.""" + _attr_native_unit_of_measurement = "mg/dL" + _attr_icon = "mdi:cloud-question" + def __init__(self, api: NightscoutAPI, name, unique_id) -> None: """Initialize the Nightscout sensor.""" self.api = api self._attr_unique_id = unique_id self._attr_name = name self._attr_extra_state_attributes: dict[str, Any] = {} - self._attr_native_unit_of_measurement = "mg/dL" - self._attr_icon = "mdi:cloud-question" - self._attr_available = False async def async_update(self) -> None: """Fetch the latest data from Nightscout REST API and update the state.""" diff --git a/homeassistant/components/noaa_tides/sensor.py b/homeassistant/components/noaa_tides/sensor.py index 7f3260c7635abc..a83f18fd6ca22a 100644 --- a/homeassistant/components/noaa_tides/sensor.py +++ b/homeassistant/components/noaa_tides/sensor.py @@ -3,6 +3,7 @@ from datetime import datetime, timedelta import logging +from typing import TYPE_CHECKING, Any, Literal, TypedDict import noaa_coops as coops import requests @@ -17,6 +18,9 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.unit_system import METRIC_SYSTEM +if TYPE_CHECKING: + from pandas import Timestamp + _LOGGER = logging.getLogger(__name__) CONF_STATION_ID = "station_id" @@ -76,40 +80,56 @@ def setup_platform( add_entities([noaa_sensor], True) +class NOAATidesData(TypedDict): + """Representation of a single tide.""" + + time_stamp: list[Timestamp] + hi_lo: list[Literal["L"] | Literal["H"]] + predicted_wl: list[float] + + class NOAATidesAndCurrentsSensor(SensorEntity): """Representation of a NOAA Tides and Currents sensor.""" _attr_attribution = "Data provided by NOAA" - def __init__(self, name, station_id, timezone, unit_system, station): + def __init__(self, name, station_id, timezone, unit_system, station) -> None: """Initialize the sensor.""" self._name = name self._station_id = station_id self._timezone = timezone self._unit_system = unit_system self._station = station - self.data = None + self.data: NOAATidesData | None = None @property - def name(self): + def name(self) -> str: """Return the name of the sensor.""" return self._name @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes of this device.""" - attr = {} + attr: dict[str, Any] = {} if self.data is None: return attr if self.data["hi_lo"][1] == "H": - attr["high_tide_time"] = self.data.index[1].strftime("%Y-%m-%dT%H:%M") + attr["high_tide_time"] = self.data["time_stamp"][1].strftime( + "%Y-%m-%dT%H:%M" + ) attr["high_tide_height"] = self.data["predicted_wl"][1] - attr["low_tide_time"] = self.data.index[2].strftime("%Y-%m-%dT%H:%M") + attr["low_tide_time"] = self.data["time_stamp"][2].strftime( + "%Y-%m-%dT%H:%M" + ) attr["low_tide_height"] = self.data["predicted_wl"][2] elif self.data["hi_lo"][1] == "L": - attr["low_tide_time"] = self.data.index[1].strftime("%Y-%m-%dT%H:%M") + attr["low_tide_time"] = self.data["time_stamp"][1].strftime( + "%Y-%m-%dT%H:%M" + ) attr["low_tide_height"] = self.data["predicted_wl"][1] - attr["high_tide_time"] = self.data.index[2].strftime("%Y-%m-%dT%H:%M") + attr["high_tide_time"] = self.data["time_stamp"][2].strftime( + "%Y-%m-%dT%H:%M" + ) attr["high_tide_height"] = self.data["predicted_wl"][2] return attr @@ -118,7 +138,7 @@ def native_value(self): """Return the state of the device.""" if self.data is None: return None - api_time = self.data.index[0] + api_time = self.data["time_stamp"][0] if self.data["hi_lo"][0] == "H": tidetime = api_time.strftime("%-I:%M %p") return f"High tide at {tidetime}" @@ -142,8 +162,13 @@ def update(self) -> None: units=self._unit_system, time_zone=self._timezone, ) - self.data = df_predictions.head() - _LOGGER.debug("Data = %s", self.data) + api_data = df_predictions.head() + self.data = NOAATidesData( + time_stamp=list(api_data.index), + hi_lo=list(api_data["hi_lo"].values), + predicted_wl=list(api_data["predicted_wl"].values), + ) + _LOGGER.debug("Data = %s", api_data) _LOGGER.debug( "Recent Tide data queried with start time set to %s", begin.strftime("%m-%d-%Y %H:%M"), diff --git a/homeassistant/components/nobo_hub/climate.py b/homeassistant/components/nobo_hub/climate.py index e3cfa04802c2a0..7041d097f3e0a4 100644 --- a/homeassistant/components/nobo_hub/climate.py +++ b/homeassistant/components/nobo_hub/climate.py @@ -74,6 +74,8 @@ class NoboZone(ClimateEntity): _attr_max_temp = MAX_TEMPERATURE _attr_min_temp = MIN_TEMPERATURE _attr_precision = PRECISION_TENTHS + _attr_hvac_modes = [HVACMode.HEAT, HVACMode.AUTO] + _attr_hvac_mode = HVACMode.AUTO _attr_preset_modes = PRESET_MODES _attr_supported_features = SUPPORT_FLAGS _attr_temperature_unit = UnitOfTemperature.CELSIUS @@ -85,8 +87,6 @@ def __init__(self, zone_id, hub: nobo, override_type) -> None: self._id = zone_id self._nobo = hub self._attr_unique_id = f"{hub.hub_serial}:{zone_id}" - self._attr_hvac_mode = HVACMode.AUTO - self._attr_hvac_modes = [HVACMode.HEAT, HVACMode.AUTO] self._override_type = override_type self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, f"{hub.hub_serial}:{zone_id}")}, diff --git a/homeassistant/components/nuheat/climate.py b/homeassistant/components/nuheat/climate.py index 18b34ea0beadf0..4daaee10ea68d7 100644 --- a/homeassistant/components/nuheat/climate.py +++ b/homeassistant/components/nuheat/climate.py @@ -77,6 +77,7 @@ class NuHeatThermostat(CoordinatorEntity, ClimateEntity): ) _attr_has_entity_name = True _attr_name = None + _attr_preset_modes = PRESET_MODES def __init__(self, coordinator, thermostat, temperature_unit): """Initialize the thermostat.""" @@ -85,6 +86,7 @@ def __init__(self, coordinator, thermostat, temperature_unit): self._temperature_unit = temperature_unit self._schedule_mode = None self._target_temperature = None + self._attr_unique_id = thermostat.serial_number @property def temperature_unit(self) -> str: @@ -102,11 +104,6 @@ def current_temperature(self): return self._thermostat.fahrenheit - @property - def unique_id(self): - """Return the unique id.""" - return self._thermostat.serial_number - @property def available(self) -> bool: """Return the unique id.""" @@ -160,11 +157,6 @@ def preset_mode(self): """Return current preset mode.""" return SCHEDULE_MODE_TO_PRESET_MODE_MAP.get(self._schedule_mode, PRESET_RUN) - @property - def preset_modes(self): - """Return available preset modes.""" - return PRESET_MODES - def set_preset_mode(self, preset_mode: str) -> None: """Update the hold mode of the thermostat.""" self._set_schedule_mode( diff --git a/homeassistant/components/number/__init__.py b/homeassistant/components/number/__init__.py index aa3566c5a95d89..4e0f5059c90da1 100644 --- a/homeassistant/components/number/__init__.py +++ b/homeassistant/components/number/__init__.py @@ -156,6 +156,10 @@ def floor_decimal(value: float, precision: float = 0) -> float: class NumberEntity(Entity): """Representation of a Number entity.""" + _entity_component_unrecorded_attributes = frozenset( + {ATTR_MIN, ATTR_MAX, ATTR_STEP, ATTR_MODE} + ) + entity_description: NumberEntityDescription _attr_device_class: NumberDeviceClass | None _attr_max_value: None diff --git a/homeassistant/components/number/recorder.py b/homeassistant/components/number/recorder.py deleted file mode 100644 index 39418a48878b3b..00000000000000 --- a/homeassistant/components/number/recorder.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.core import HomeAssistant, callback - -from . import ATTR_MAX, ATTR_MIN, ATTR_MODE, ATTR_STEP - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude static attributes from being recorded in the database.""" - return { - ATTR_MIN, - ATTR_MAX, - ATTR_STEP, - ATTR_MODE, - } diff --git a/homeassistant/components/nut/sensor.py b/homeassistant/components/nut/sensor.py index 9151a86a9f871e..165db8bb704b2f 100644 --- a/homeassistant/components/nut/sensor.py +++ b/homeassistant/components/nut/sensor.py @@ -491,6 +491,33 @@ entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), + "input.L1-N.voltage": SensorEntityDescription( + key="input.L1-N.voltage", + translation_key="input_l1_n_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.L2-N.voltage": SensorEntityDescription( + key="input.L2-N.voltage", + translation_key="input_l2_n_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.L3-N.voltage": SensorEntityDescription( + key="input.L3-N.voltage", + translation_key="input_l3_n_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), "input.frequency": SensorEntityDescription( key="input.frequency", translation_key="input_frequency", @@ -515,6 +542,69 @@ entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), + "input.L1.frequency": SensorEntityDescription( + key="input.L1.frequency", + translation_key="input_l1_frequency", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + device_class=SensorDeviceClass.FREQUENCY, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.L2.frequency": SensorEntityDescription( + key="input.L2.frequency", + translation_key="input_l2_frequency", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + device_class=SensorDeviceClass.FREQUENCY, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.L3.frequency": SensorEntityDescription( + key="input.L3.frequency", + translation_key="input_l3_frequency", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + device_class=SensorDeviceClass.FREQUENCY, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.bypass.current": SensorEntityDescription( + key="input.bypass.current", + translation_key="input_bypass_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.bypass.L1.current": SensorEntityDescription( + key="input.bypass.L1.current", + translation_key="input_bypass_l1_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.bypass.L2.current": SensorEntityDescription( + key="input.bypass.L2.current", + translation_key="input_bypass_l2_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.bypass.L3.current": SensorEntityDescription( + key="input.bypass.L3.current", + translation_key="input_bypass_l3_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), "input.bypass.frequency": SensorEntityDescription( key="input.bypass.frequency", translation_key="input_bypass_frequency", @@ -531,6 +621,78 @@ entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), + "input.bypass.realpower": SensorEntityDescription( + key="input.bypass.realpower", + translation_key="input_bypass_realpower", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.bypass.L1.realpower": SensorEntityDescription( + key="input.bypass.L1.realpower", + translation_key="input_bypass_l1_realpower", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.bypass.L2.realpower": SensorEntityDescription( + key="input.bypass.L2.realpower", + translation_key="input_bypass_l2_realpower", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.bypass.L3.realpower": SensorEntityDescription( + key="input.bypass.L3.realpower", + translation_key="input_bypass_l3_realpower", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.bypass.voltage": SensorEntityDescription( + key="input.bypass.voltage", + translation_key="input_bypass_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.bypass.L1-N.voltage": SensorEntityDescription( + key="input.bypass.L1-N.voltage", + translation_key="input_bypass_l1_n_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.bypass.L2-N.voltage": SensorEntityDescription( + key="input.bypass.L2-N.voltage", + translation_key="input_bypass_l2_n_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.bypass.L3-N.voltage": SensorEntityDescription( + key="input.bypass.L3-N.voltage", + translation_key="input_bypass_l3_n_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), "input.current": SensorEntityDescription( key="input.current", translation_key="input_current", @@ -540,6 +702,33 @@ entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), + "input.L1.current": SensorEntityDescription( + key="input.L1.current", + translation_key="input_l1_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.L2.current": SensorEntityDescription( + key="input.L2.current", + translation_key="input_l2_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.L3.current": SensorEntityDescription( + key="input.L3.current", + translation_key="input_l3_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), "input.phases": SensorEntityDescription( key="input.phases", translation_key="input_phases", @@ -556,6 +745,33 @@ entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), + "input.L1.realpower": SensorEntityDescription( + key="input.L1.realpower", + translation_key="input_l1_realpower", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.L2.realpower": SensorEntityDescription( + key="input.L2.realpower", + translation_key="input_l2_realpower", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.L3.realpower": SensorEntityDescription( + key="input.L3.realpower", + translation_key="input_l3_realpower", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), "output.power.nominal": SensorEntityDescription( key="output.power.nominal", translation_key="output_power_nominal", @@ -564,6 +780,30 @@ entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), + "output.L1.power.percent": SensorEntityDescription( + key="output.L1.power.percent", + translation_key="output_l1_power_percent", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:gauge", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "output.L2.power.percent": SensorEntityDescription( + key="output.L2.power.percent", + translation_key="output_l2_power_percent", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:gauge", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "output.L3.power.percent": SensorEntityDescription( + key="output.L3.power.percent", + translation_key="output_l3_power_percent", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:gauge", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), "output.current": SensorEntityDescription( key="output.current", translation_key="output_current", @@ -581,6 +821,33 @@ entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), + "output.L1.current": SensorEntityDescription( + key="output.L1.current", + translation_key="output_l1_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "output.L2.current": SensorEntityDescription( + key="output.L2.current", + translation_key="output_l2_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "output.L3.current": SensorEntityDescription( + key="output.L3.current", + translation_key="output_l3_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), "output.voltage": SensorEntityDescription( key="output.voltage", translation_key="output_voltage", @@ -596,6 +863,33 @@ entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), + "output.L1-N.voltage": SensorEntityDescription( + key="output.L1-N.voltage", + translation_key="output_l1_n_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "output.L2-N.voltage": SensorEntityDescription( + key="output.L2-N.voltage", + translation_key="output_l2_n_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "output.L3-N.voltage": SensorEntityDescription( + key="output.L3-N.voltage", + translation_key="output_l3_n_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), "output.frequency": SensorEntityDescription( key="output.frequency", translation_key="output_frequency", @@ -646,6 +940,33 @@ entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), + "output.L1.realpower": SensorEntityDescription( + key="output.L1.realpower", + translation_key="output_l1_realpower", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "output.L2.realpower": SensorEntityDescription( + key="output.L2.realpower", + translation_key="output_l2_realpower", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "output.L3.realpower": SensorEntityDescription( + key="output.L3.realpower", + translation_key="output_l3_realpower", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), "ambient.humidity": SensorEntityDescription( key="ambient.humidity", translation_key="ambient_humidity", diff --git a/homeassistant/components/nut/strings.json b/homeassistant/components/nut/strings.json index a07e0ec2f7c847..2827911a3aa318 100644 --- a/homeassistant/components/nut/strings.json +++ b/homeassistant/components/nut/strings.json @@ -90,31 +90,73 @@ "battery_voltage_high": { "name": "High battery voltage" }, "battery_voltage_low": { "name": "Low battery voltage" }, "battery_voltage_nominal": { "name": "Nominal battery voltage" }, + "input_bypass_current": { "name": "Input bypass current" }, + "input_bypass_l1_current": { "name": "Input bypass L1 current" }, + "input_bypass_l2_current": { "name": "Input bypass L2 current" }, + "input_bypass_l3_current": { "name": "Input bypass L3 current" }, + "input_bypass_voltage": { "name": "Input bypass voltage" }, + "input_bypass_l1_n_voltage": { "name": "Input bypass L1-N voltage" }, + "input_bypass_l2_n_voltage": { "name": "Input bypass L2-N voltage" }, + "input_bypass_l3_n_voltage": { "name": "Input bypass L3-N voltage" }, "input_bypass_frequency": { "name": "Input bypass frequency" }, "input_bypass_phases": { "name": "Input bypass phases" }, + "input_bypass_realpower": { "name": "Current input bypass real power" }, + "input_bypass_l1_realpower": { + "name": "Current input bypass L1 real power" + }, + "input_bypass_l2_realpower": { + "name": "Current input bypass L2 real power" + }, + "input_bypass_l3_realpower": { + "name": "Current input bypass L3 real power" + }, "input_current": { "name": "Input current" }, + "input_l1_current": { "name": "Input L1 current" }, + "input_l2_current": { "name": "Input L2 current" }, + "input_l3_current": { "name": "Input L3 current" }, "input_frequency": { "name": "Input line frequency" }, "input_frequency_nominal": { "name": "Nominal input line frequency" }, "input_frequency_status": { "name": "Input frequency status" }, + "input_l1_frequency": { "name": "Input L1 line frequency" }, + "input_l2_frequency": { "name": "Input L2 line frequency" }, + "input_l3_frequency": { "name": "Input L3 line frequency" }, "input_phases": { "name": "Input phases" }, "input_realpower": { "name": "Current input real power" }, + "input_l1_realpower": { "name": "Current input L1 real power" }, + "input_l2_realpower": { "name": "Current input L2 real power" }, + "input_l3_realpower": { "name": "Current input L3 real power" }, "input_sensitivity": { "name": "Input power sensitivity" }, "input_transfer_high": { "name": "High voltage transfer" }, "input_transfer_low": { "name": "Low voltage transfer" }, "input_transfer_reason": { "name": "Voltage transfer reason" }, "input_voltage": { "name": "Input voltage" }, "input_voltage_nominal": { "name": "Nominal input voltage" }, + "input_l1_n_voltage": { "name": "Input L1 voltage" }, + "input_l2_n_voltage": { "name": "Input L2 voltage" }, + "input_l3_n_voltage": { "name": "Input L3 voltage" }, "output_current": { "name": "Output current" }, "output_current_nominal": { "name": "Nominal output current" }, + "output_l1_current": { "name": "Output L1 current" }, + "output_l2_current": { "name": "Output L2 current" }, + "output_l3_current": { "name": "Output L3 current" }, "output_frequency": { "name": "Output frequency" }, "output_frequency_nominal": { "name": "Nominal output frequency" }, "output_phases": { "name": "Output phases" }, "output_power": { "name": "Output apparent power" }, + "output_l2_power_percent": { "name": "Output L2 power usage" }, + "output_l1_power_percent": { "name": "Output L1 power usage" }, + "output_l3_power_percent": { "name": "Output L3 power usage" }, "output_power_nominal": { "name": "Nominal output power" }, "output_realpower": { "name": "Current output real power" }, "output_realpower_nominal": { "name": "Nominal output real power" }, + "output_l1_realpower": { "name": "Current output L1 real power" }, + "output_l2_realpower": { "name": "Current output L2 real power" }, + "output_l3_realpower": { "name": "Current output L3 real power" }, "output_voltage": { "name": "Output voltage" }, "output_voltage_nominal": { "name": "Nominal output voltage" }, + "output_l1_n_voltage": { "name": "Output L1-N voltage" }, + "output_l2_n_voltage": { "name": "Output L2-N voltage" }, + "output_l3_n_voltage": { "name": "Output L3-N voltage" }, "ups_alarm": { "name": "Alarms" }, "ups_beeper_status": { "name": "Beeper status" }, "ups_contacts": { "name": "External contacts" }, diff --git a/homeassistant/components/nws/sensor.py b/homeassistant/components/nws/sensor.py index 7c49ca278a7cf7..ecf9d39ae559d0 100644 --- a/homeassistant/components/nws/sensor.py +++ b/homeassistant/components/nws/sensor.py @@ -23,7 +23,6 @@ UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.dt import utcnow @@ -163,6 +162,7 @@ class NWSSensor(CoordinatorEntity[NwsDataUpdateCoordinator], SensorEntity): entity_description: NWSSensorEntityDescription _attr_attribution = ATTRIBUTION + _attr_entity_registry_enabled_default = False def __init__( self, @@ -175,13 +175,17 @@ def __init__( """Initialise the platform with a data instance.""" super().__init__(nws_data.coordinator_observation) self._nws = nws_data.api - self._latitude = entry_data[CONF_LATITUDE] - self._longitude = entry_data[CONF_LONGITUDE] + latitude = entry_data[CONF_LATITUDE] + longitude = entry_data[CONF_LONGITUDE] self.entity_description = description self._attr_name = f"{station} {description.name}" if hass.config.units is US_CUSTOMARY_SYSTEM: self._attr_native_unit_of_measurement = description.unit_convert + self._attr_device_info = device_info(latitude, longitude) + self._attr_unique_id = ( + f"{base_unique_id(latitude, longitude)}_{description.key}" + ) @property def native_value(self) -> float | None: @@ -219,11 +223,6 @@ def native_value(self) -> float | None: return round(value) return value - @property - def unique_id(self) -> str: - """Return a unique_id for this entity.""" - return f"{base_unique_id(self._latitude, self._longitude)}_{self.entity_description.key}" - @property def available(self) -> bool: """Return if state is available.""" @@ -235,13 +234,3 @@ def available(self) -> bool: else: last_success_time = False return self.coordinator.last_update_success or last_success_time - - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - return False - - @property - def device_info(self) -> DeviceInfo: - """Return device info.""" - return device_info(self._latitude, self._longitude) diff --git a/homeassistant/components/nws/weather.py b/homeassistant/components/nws/weather.py index 0f594133f69933..9d41e54ccd0c00 100644 --- a/homeassistant/components/nws/weather.py +++ b/homeassistant/components/nws/weather.py @@ -32,7 +32,6 @@ ) 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 AddEntitiesCallback from homeassistant.util.dt import utcnow from homeassistant.util.unit_conversion import SpeedConverter, TemperatureConverter @@ -121,6 +120,10 @@ class NWSWeather(CoordinatorWeatherEntity): _attr_supported_features = ( WeatherEntityFeature.FORECAST_HOURLY | WeatherEntityFeature.FORECAST_TWICE_DAILY ) + _attr_native_temperature_unit = UnitOfTemperature.CELSIUS + _attr_native_pressure_unit = UnitOfPressure.PA + _attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR + _attr_native_visibility_unit = UnitOfLength.METERS def __init__( self, @@ -137,8 +140,8 @@ def __init__( twice_daily_forecast_valid=FORECAST_VALID_TIME, ) self.nws = nws_data.api - self.latitude = entry_data[CONF_LATITUDE] - self.longitude = entry_data[CONF_LONGITUDE] + latitude = entry_data[CONF_LATITUDE] + longitude = entry_data[CONF_LONGITUDE] if mode == DAYNIGHT: self.coordinator_forecast_legacy = nws_data.coordinator_forecast else: @@ -146,6 +149,7 @@ def __init__( self.station = self.nws.station self.mode = mode + self._attr_entity_registry_enabled_default = mode == DAYNIGHT self.observation: dict[str, Any] | None = None self._forecast_hourly: list[dict[str, Any]] | None = None @@ -153,6 +157,8 @@ def __init__( self._forecast_twice_daily: list[dict[str, Any]] | None = None self._attr_unique_id = _calculate_unique_id(entry_data, mode) + self._attr_device_info = device_info(latitude, longitude) + self._attr_name = f"{self.station} {self.mode.title()}" async def async_added_to_hass(self) -> None: """Set up a listener and load data.""" @@ -193,11 +199,6 @@ def _handle_legacy_forecast_coordinator_update(self) -> None: self._forecast_legacy = self.nws.forecast_hourly self.async_write_ha_state() - @property - def name(self) -> str: - """Return the name of the station.""" - return f"{self.station} {self.mode.title()}" - @property def native_temperature(self) -> float | None: """Return the current temperature.""" @@ -205,11 +206,6 @@ def native_temperature(self) -> float | None: return self.observation.get("temperature") return None - @property - def native_temperature_unit(self) -> str: - """Return the current temperature unit.""" - return UnitOfTemperature.CELSIUS - @property def native_pressure(self) -> int | None: """Return the current pressure.""" @@ -217,11 +213,6 @@ def native_pressure(self) -> int | None: return self.observation.get("seaLevelPressure") return None - @property - def native_pressure_unit(self) -> str: - """Return the current pressure unit.""" - return UnitOfPressure.PA - @property def humidity(self) -> float | None: """Return the name of the sensor.""" @@ -236,11 +227,6 @@ def native_wind_speed(self) -> float | None: return self.observation.get("windSpeed") return None - @property - def native_wind_speed_unit(self) -> str: - """Return the current windspeed.""" - return UnitOfSpeed.KILOMETERS_PER_HOUR - @property def wind_bearing(self) -> int | None: """Return the current wind bearing (degrees).""" @@ -267,11 +253,6 @@ def native_visibility(self) -> int | None: return self.observation.get("visibility") return None - @property - def native_visibility_unit(self) -> str: - """Return visibility unit.""" - return UnitOfLength.METERS - def _forecast( self, nws_forecast: list[dict[str, Any]] | None, mode: str ) -> list[Forecast] | None: @@ -372,13 +353,3 @@ async def async_update(self) -> None: """ await self.coordinator.async_request_refresh() await self.coordinator_forecast_legacy.async_request_refresh() - - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - return self.mode == DAYNIGHT - - @property - def device_info(self) -> DeviceInfo: - """Return device info.""" - return device_info(self.latitude, self.longitude) diff --git a/homeassistant/components/nx584/binary_sensor.py b/homeassistant/components/nx584/binary_sensor.py index 853f5686831732..ca55ea25c40e03 100644 --- a/homeassistant/components/nx584/binary_sensor.py +++ b/homeassistant/components/nx584/binary_sensor.py @@ -131,9 +131,9 @@ def __init__(self, client, zone_sensors): def _process_zone_event(self, event): zone = event["zone"] - # pylint: disable=protected-access if not (zone_sensor := self._zone_sensors.get(zone)): return + # pylint: disable-next=protected-access zone_sensor._zone["state"] = event["zone_state"] zone_sensor.schedule_update_ha_state() diff --git a/homeassistant/components/nzbget/__init__.py b/homeassistant/components/nzbget/__init__.py index c3b6aab619bae0..9d6fafd30c7c4f 100644 --- a/homeassistant/components/nzbget/__init__.py +++ b/homeassistant/components/nzbget/__init__.py @@ -2,7 +2,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_SCAN_INTERVAL, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo @@ -12,7 +12,6 @@ ATTR_SPEED, DATA_COORDINATOR, DATA_UNDO_UPDATE_LISTENER, - DEFAULT_SCAN_INTERVAL, DEFAULT_SPEED_LIMIT, DOMAIN, SERVICE_PAUSE, @@ -34,18 +33,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up NZBGet from a config entry.""" hass.data.setdefault(DOMAIN, {}) - if not entry.options: - options = { - CONF_SCAN_INTERVAL: entry.data.get( - CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL - ), - } - hass.config_entries.async_update_entry(entry, options=options) - coordinator = NZBGetDataUpdateCoordinator( hass, config=entry.data, - options=entry.options, ) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/nzbget/config_flow.py b/homeassistant/components/nzbget/config_flow.py index 732ef87976248f..782ec791eebeec 100644 --- a/homeassistant/components/nzbget/config_flow.py +++ b/homeassistant/components/nzbget/config_flow.py @@ -6,28 +6,19 @@ import voluptuous as vol -from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow +from homeassistant.config_entries import ConfigFlow from homeassistant.const import ( CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT, - CONF_SCAN_INTERVAL, CONF_SSL, CONF_USERNAME, CONF_VERIFY_SSL, ) -from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult -from .const import ( - DEFAULT_NAME, - DEFAULT_PORT, - DEFAULT_SCAN_INTERVAL, - DEFAULT_SSL, - DEFAULT_VERIFY_SSL, - DOMAIN, -) +from .const import DEFAULT_NAME, DEFAULT_PORT, DEFAULT_SSL, DEFAULT_VERIFY_SSL, DOMAIN from .coordinator import NZBGetAPI, NZBGetAPIException _LOGGER = logging.getLogger(__name__) @@ -55,12 +46,6 @@ class NZBGetConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 - @staticmethod - @callback - def async_get_options_flow(config_entry: ConfigEntry) -> NZBGetOptionsFlowHandler: - """Get the options flow for this handler.""" - return NZBGetOptionsFlowHandler(config_entry) - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -106,29 +91,3 @@ async def async_step_user( data_schema=vol.Schema(data_schema), errors=errors or {}, ) - - -class NZBGetOptionsFlowHandler(OptionsFlow): - """Handle NZBGet client options.""" - - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize options flow.""" - self.config_entry = config_entry - - async def async_step_init( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Manage NZBGet options.""" - if user_input is not None: - return self.async_create_entry(title="", data=user_input) - - options = { - vol.Optional( - CONF_SCAN_INTERVAL, - default=self.config_entry.options.get( - CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL - ), - ): int, - } - - return self.async_show_form(step_id="init", data_schema=vol.Schema(options)) diff --git a/homeassistant/components/nzbget/const.py b/homeassistant/components/nzbget/const.py index 928487738eb8a0..7838d64c6d78e9 100644 --- a/homeassistant/components/nzbget/const.py +++ b/homeassistant/components/nzbget/const.py @@ -11,7 +11,6 @@ # Defaults DEFAULT_NAME = "NZBGet" DEFAULT_PORT = 6789 -DEFAULT_SCAN_INTERVAL = 5 # time in seconds DEFAULT_SPEED_LIMIT = 1000 # 1 Megabyte/Sec DEFAULT_SSL = False DEFAULT_VERIFY_SSL = False diff --git a/homeassistant/components/nzbget/coordinator.py b/homeassistant/components/nzbget/coordinator.py index 7326fa50dd54ab..dcefe25eae95cf 100644 --- a/homeassistant/components/nzbget/coordinator.py +++ b/homeassistant/components/nzbget/coordinator.py @@ -11,7 +11,6 @@ CONF_HOST, CONF_PASSWORD, CONF_PORT, - CONF_SCAN_INTERVAL, CONF_SSL, CONF_USERNAME, CONF_VERIFY_SSL, @@ -32,7 +31,6 @@ def __init__( hass: HomeAssistant, *, config: Mapping[str, Any], - options: Mapping[str, Any], ) -> None: """Initialize global NZBGet data updater.""" self.nzbget = NZBGetAPI( @@ -47,13 +45,8 @@ def __init__( self._completed_downloads_init = False self._completed_downloads = set[tuple]() - update_interval = timedelta(seconds=options[CONF_SCAN_INTERVAL]) - super().__init__( - hass, - _LOGGER, - name=DOMAIN, - update_interval=update_interval, + hass, _LOGGER, name=DOMAIN, update_interval=timedelta(seconds=5) ) def _check_completed_downloads(self, history): diff --git a/homeassistant/components/nzbget/strings.json b/homeassistant/components/nzbget/strings.json index a1faa63bb3958f..4da9a0b505ede3 100644 --- a/homeassistant/components/nzbget/strings.json +++ b/homeassistant/components/nzbget/strings.json @@ -23,15 +23,6 @@ "unknown": "[%key:common::config_flow::error::unknown%]" } }, - "options": { - "step": { - "init": { - "data": { - "scan_interval": "Update frequency (seconds)" - } - } - } - }, "entity": { "sensor": { "article_cache": { diff --git a/homeassistant/components/nzbget/switch.py b/homeassistant/components/nzbget/switch.py index e6a2b213873e3f..5d72cae37cf5be 100644 --- a/homeassistant/components/nzbget/switch.py +++ b/homeassistant/components/nzbget/switch.py @@ -47,7 +47,7 @@ def __init__( entry_name: str, ) -> None: """Initialize a new NZBGet switch.""" - self._unique_id = f"{entry_id}_download" + self._attr_unique_id = f"{entry_id}_download" super().__init__( coordinator=coordinator, @@ -55,11 +55,6 @@ def __init__( entry_name=entry_name, ) - @property - def unique_id(self) -> str: - """Return the unique ID of the switch.""" - return self._unique_id - @property def is_on(self): """Return the state of the switch.""" diff --git a/homeassistant/components/octoprint/binary_sensor.py b/homeassistant/components/octoprint/binary_sensor.py index b0e43bd74e0fea..0bc13f66415eda 100644 --- a/homeassistant/components/octoprint/binary_sensor.py +++ b/homeassistant/components/octoprint/binary_sensor.py @@ -52,11 +52,7 @@ def __init__( self._device_id = device_id self._attr_name = f"OctoPrint {sensor_type}" self._attr_unique_id = f"{sensor_type}-{device_id}" - - @property - def device_info(self): - """Device info.""" - return self.coordinator.device_info + self._attr_device_info = coordinator.device_info @property def is_on(self): diff --git a/homeassistant/components/octoprint/button.py b/homeassistant/components/octoprint/button.py index 578554da5bd1f6..b2c1672b3e4d07 100644 --- a/homeassistant/components/octoprint/button.py +++ b/homeassistant/components/octoprint/button.py @@ -5,7 +5,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -53,11 +52,7 @@ def __init__( self._device_id = device_id self._attr_name = f"OctoPrint {button_type}" self._attr_unique_id = f"{button_type}-{device_id}" - - @property - def device_info(self) -> DeviceInfo: - """Device info.""" - return self.coordinator.device_info + self._attr_device_info = coordinator.device_info @property def available(self) -> bool: diff --git a/homeassistant/components/octoprint/sensor.py b/homeassistant/components/octoprint/sensor.py index 17bea7b8ac56cd..1ea29c2b4e8bcc 100644 --- a/homeassistant/components/octoprint/sensor.py +++ b/homeassistant/components/octoprint/sensor.py @@ -104,11 +104,7 @@ def __init__( self._device_id = device_id self._attr_name = f"OctoPrint {sensor_type}" self._attr_unique_id = f"{sensor_type}-{device_id}" - - @property - def device_info(self): - """Device info.""" - return self.coordinator.device_info + self._attr_device_info = coordinator.device_info class OctoPrintStatusSensor(OctoPrintSensorBase): diff --git a/homeassistant/components/octoprint/strings.json b/homeassistant/components/octoprint/strings.json index 23cdf6ce56ef53..c6dbfe6f9c42f9 100644 --- a/homeassistant/components/octoprint/strings.json +++ b/homeassistant/components/octoprint/strings.json @@ -6,8 +6,8 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "path": "Application Path", - "port": "Port Number", - "ssl": "Use SSL", + "port": "[%key:common::config_flow::data::port%]", + "ssl": "[%key:common::config_flow::data::ssl%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]", "username": "[%key:common::config_flow::data::username%]" } diff --git a/homeassistant/components/omnilogic/sensor.py b/homeassistant/components/omnilogic/sensor.py index 5cb7605b854259..be082584308953 100644 --- a/homeassistant/components/omnilogic/sensor.py +++ b/homeassistant/components/omnilogic/sensor.py @@ -66,7 +66,7 @@ def __init__( coordinator: OmniLogicUpdateCoordinator, kind: str, name: str, - device_class: str, + device_class: SensorDeviceClass | None, icon: str, unit: str, item_id: tuple, @@ -85,20 +85,10 @@ def __init__( unit_type = coordinator.data[backyard_id].get("Unit-of-Measurement") self._unit_type = unit_type - self._device_class = device_class - self._unit = unit + self._attr_device_class = device_class + self._attr_native_unit_of_measurement = unit self._state_key = state_key - @property - def device_class(self): - """Return the device class of the entity.""" - return self._device_class - - @property - def native_unit_of_measurement(self): - """Return the right unit of measure.""" - return self._unit - class OmniLogicTemperatureSensor(OmnilogicSensor): """Define an OmniLogic Temperature (Air/Water) Sensor.""" @@ -123,7 +113,7 @@ def native_value(self): self._attrs["hayward_temperature"] = hayward_state self._attrs["hayward_unit_of_measure"] = hayward_unit_of_measure - self._unit = UnitOfTemperature.FAHRENHEIT + self._attr_native_unit_of_measurement = UnitOfTemperature.FAHRENHEIT return state @@ -143,10 +133,10 @@ def native_value(self): pump_speed = self.coordinator.data[self._item_id][self._state_key] if pump_type == "VARIABLE": - self._unit = PERCENTAGE + self._attr_native_unit_of_measurement = PERCENTAGE state = pump_speed elif pump_type == "DUAL": - self._unit = None + self._attr_native_unit_of_measurement = None if pump_speed == 0: state = "off" elif pump_speed == self.coordinator.data[self._item_id].get( @@ -171,13 +161,12 @@ def native_value(self): """Return the state for the salt level sensor.""" salt_return = self.coordinator.data[self._item_id][self._state_key] - unit_of_measurement = self._unit if self._unit_type == "Metric": salt_return = round(int(salt_return) / 1000, 2) - unit_of_measurement = f"{UnitOfMass.GRAMS}/{UnitOfVolume.LITERS}" - - self._unit = unit_of_measurement + self._attr_native_unit_of_measurement = ( + f"{UnitOfMass.GRAMS}/{UnitOfVolume.LITERS}" + ) return salt_return @@ -188,9 +177,7 @@ class OmniLogicChlorinatorSensor(OmnilogicSensor): @property def native_value(self): """Return the state for the chlorinator sensor.""" - state = self.coordinator.data[self._item_id][self._state_key] - - return state + return self.coordinator.data[self._item_id][self._state_key] class OmniLogicPHSensor(OmnilogicSensor): @@ -224,7 +211,7 @@ def __init__( name: str, kind: str, item_id: tuple, - device_class: str, + device_class: SensorDeviceClass | None, icon: str, unit: str, ) -> None: diff --git a/homeassistant/components/ondilo_ico/sensor.py b/homeassistant/components/ondilo_ico/sensor.py index 4345f3498fd653..90c79003b8adcd 100644 --- a/homeassistant/components/ondilo_ico/sensor.py +++ b/homeassistant/components/ondilo_ico/sensor.py @@ -153,7 +153,13 @@ def __init__( pooldata = self._pooldata() self._attr_unique_id = f"{pooldata['ICO']['serial_number']}-{description.key}" - self._device_name = pooldata["name"] + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, pooldata["ICO"]["serial_number"])}, + manufacturer="Ondilo", + model="ICO", + name=pooldata["name"], + sw_version=pooldata["ICO"]["sw_version"], + ) def _pooldata(self): """Get pool data dict.""" @@ -177,15 +183,3 @@ def _devdata(self): def native_value(self): """Last value of the sensor.""" return self._devdata()["value"] - - @property - def device_info(self) -> DeviceInfo: - """Return the device info for the sensor.""" - pooldata = self._pooldata() - return DeviceInfo( - identifiers={(DOMAIN, pooldata["ICO"]["serial_number"])}, - manufacturer="Ondilo", - model="ICO", - name=self._device_name, - sw_version=pooldata["ICO"]["sw_version"], - ) diff --git a/homeassistant/components/onvif/camera.py b/homeassistant/components/onvif/camera.py index 96ce70344fd79d..013dd2e453f22e 100644 --- a/homeassistant/components/onvif/camera.py +++ b/homeassistant/components/onvif/camera.py @@ -113,29 +113,20 @@ def __init__(self, device: ONVIFDevice, profile: Profile) -> None: ) self._stream_uri: str | None = None self._stream_uri_future: asyncio.Future[str] | None = None + self._attr_entity_registry_enabled_default = ( + device.max_resolution == profile.video.resolution.width + ) + if profile.index: + self._attr_unique_id = f"{self.mac_or_serial}_{profile.index}" + else: + self._attr_unique_id = self.mac_or_serial + self._attr_name = f"{device.name} {profile.name}" @property def use_stream_for_stills(self) -> bool: """Whether or not to use stream to generate stills.""" return bool(self.stream and self.stream.dynamic_stream_settings.preload_stream) - @property - def name(self) -> str: - """Return the name of this camera.""" - return f"{self.device.name} {self.profile.name}" - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - if self.profile.index: - return f"{self.mac_or_serial}_{self.profile.index}" - return self.mac_or_serial - - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - return self.device.max_resolution == self.profile.video.resolution.width - async def stream_source(self): """Return the stream source.""" return await self._async_get_stream_uri() diff --git a/homeassistant/components/onvif/event.py b/homeassistant/components/onvif/event.py index bb42e63c52e759..603957a230e2ac 100644 --- a/homeassistant/components/onvif/event.py +++ b/homeassistant/components/onvif/event.py @@ -142,7 +142,6 @@ def async_callback_listeners(self) -> None: for update_callback in self._listeners: update_callback() - # pylint: disable=protected-access async def async_parse_messages(self, messages) -> None: """Parse notification message.""" unique_id = self.unique_id @@ -160,7 +159,7 @@ async def async_parse_messages(self, messages) -> None: # # Our parser expects the topic to be # tns1:RuleEngine/CellMotionDetector/Motion - topic = msg.Topic._value_1.rstrip("/.") + topic = msg.Topic._value_1.rstrip("/.") # pylint: disable=protected-access if not (parser := PARSERS.get(topic)): if topic not in UNHANDLED_TOPICS: diff --git a/homeassistant/components/onvif/parsers.py b/homeassistant/components/onvif/parsers.py index 8e6e3e25861c86..3f405767c54947 100644 --- a/homeassistant/components/onvif/parsers.py +++ b/homeassistant/components/onvif/parsers.py @@ -42,21 +42,21 @@ def local_datetime_or_none(value: str) -> datetime.datetime | None: @PARSERS.register("tns1:VideoSource/MotionAlarm") -# pylint: disable=protected-access async def async_parse_motion_alarm(uid: str, msg) -> Event | None: """Handle parsing event message. Topic: tns1:VideoSource/MotionAlarm """ try: - source = msg.Message._value_1.Source.SimpleItem[0].Value + value_1 = msg.Message._value_1 # pylint: disable=protected-access + source = value_1.Source.SimpleItem[0].Value return Event( - f"{uid}_{msg.Topic._value_1}_{source}", + f"{uid}_{value_1}_{source}", "Motion Alarm", "binary_sensor", "motion", None, - msg.Message._value_1.Data.SimpleItem[0].Value == "true", + value_1.Data.SimpleItem[0].Value == "true", ) except (AttributeError, KeyError): return None @@ -65,21 +65,21 @@ async def async_parse_motion_alarm(uid: str, msg) -> Event | None: @PARSERS.register("tns1:VideoSource/ImageTooBlurry/AnalyticsService") @PARSERS.register("tns1:VideoSource/ImageTooBlurry/ImagingService") @PARSERS.register("tns1:VideoSource/ImageTooBlurry/RecordingService") -# pylint: disable=protected-access async def async_parse_image_too_blurry(uid: str, msg) -> Event | None: """Handle parsing event message. Topic: tns1:VideoSource/ImageTooBlurry/* """ try: - source = msg.Message._value_1.Source.SimpleItem[0].Value + value_1 = msg.Message._value_1 # pylint: disable=protected-access + source = value_1.Source.SimpleItem[0].Value return Event( - f"{uid}_{msg.Topic._value_1}_{source}", + f"{uid}_{value_1}_{source}", "Image Too Blurry", "binary_sensor", "problem", None, - msg.Message._value_1.Data.SimpleItem[0].Value == "true", + value_1.Data.SimpleItem[0].Value == "true", EntityCategory.DIAGNOSTIC, ) except (AttributeError, KeyError): @@ -89,21 +89,21 @@ async def async_parse_image_too_blurry(uid: str, msg) -> Event | None: @PARSERS.register("tns1:VideoSource/ImageTooDark/AnalyticsService") @PARSERS.register("tns1:VideoSource/ImageTooDark/ImagingService") @PARSERS.register("tns1:VideoSource/ImageTooDark/RecordingService") -# pylint: disable=protected-access async def async_parse_image_too_dark(uid: str, msg) -> Event | None: """Handle parsing event message. Topic: tns1:VideoSource/ImageTooDark/* """ try: - source = msg.Message._value_1.Source.SimpleItem[0].Value + value_1 = msg.Message._value_1 # pylint: disable=protected-access + source = value_1.Source.SimpleItem[0].Value return Event( - f"{uid}_{msg.Topic._value_1}_{source}", + f"{uid}_{value_1}_{source}", "Image Too Dark", "binary_sensor", "problem", None, - msg.Message._value_1.Data.SimpleItem[0].Value == "true", + value_1.Data.SimpleItem[0].Value == "true", EntityCategory.DIAGNOSTIC, ) except (AttributeError, KeyError): @@ -113,21 +113,21 @@ async def async_parse_image_too_dark(uid: str, msg) -> Event | None: @PARSERS.register("tns1:VideoSource/ImageTooBright/AnalyticsService") @PARSERS.register("tns1:VideoSource/ImageTooBright/ImagingService") @PARSERS.register("tns1:VideoSource/ImageTooBright/RecordingService") -# pylint: disable=protected-access async def async_parse_image_too_bright(uid: str, msg) -> Event | None: """Handle parsing event message. Topic: tns1:VideoSource/ImageTooBright/* """ try: - source = msg.Message._value_1.Source.SimpleItem[0].Value + value_1 = msg.Message._value_1 # pylint: disable=protected-access + source = value_1.Source.SimpleItem[0].Value return Event( - f"{uid}_{msg.Topic._value_1}_{source}", + f"{uid}_{value_1}_{source}", "Image Too Bright", "binary_sensor", "problem", None, - msg.Message._value_1.Data.SimpleItem[0].Value == "true", + value_1.Data.SimpleItem[0].Value == "true", EntityCategory.DIAGNOSTIC, ) except (AttributeError, KeyError): @@ -137,28 +137,27 @@ async def async_parse_image_too_bright(uid: str, msg) -> Event | None: @PARSERS.register("tns1:VideoSource/GlobalSceneChange/AnalyticsService") @PARSERS.register("tns1:VideoSource/GlobalSceneChange/ImagingService") @PARSERS.register("tns1:VideoSource/GlobalSceneChange/RecordingService") -# pylint: disable=protected-access async def async_parse_scene_change(uid: str, msg) -> Event | None: """Handle parsing event message. Topic: tns1:VideoSource/GlobalSceneChange/* """ try: - source = msg.Message._value_1.Source.SimpleItem[0].Value + value_1 = msg.Message._value_1 # pylint: disable=protected-access + source = value_1.Source.SimpleItem[0].Value return Event( - f"{uid}_{msg.Topic._value_1}_{source}", + f"{uid}_{value_1}_{source}", "Global Scene Change", "binary_sensor", "problem", None, - msg.Message._value_1.Data.SimpleItem[0].Value == "true", + value_1.Data.SimpleItem[0].Value == "true", ) except (AttributeError, KeyError): return None @PARSERS.register("tns1:AudioAnalytics/Audio/DetectedSound") -# pylint: disable=protected-access async def async_parse_detected_sound(uid: str, msg) -> Event | None: """Handle parsing event message. @@ -168,7 +167,8 @@ async def async_parse_detected_sound(uid: str, msg) -> Event | None: audio_source = "" audio_analytics = "" rule = "" - for source in msg.Message._value_1.Source.SimpleItem: + value_1 = msg.Message._value_1 # pylint: disable=protected-access + for source in value_1.Source.SimpleItem: if source.Name == "AudioSourceConfigurationToken": audio_source = source.Value if source.Name == "AudioAnalyticsConfigurationToken": @@ -177,19 +177,18 @@ async def async_parse_detected_sound(uid: str, msg) -> Event | None: rule = source.Value return Event( - f"{uid}_{msg.Topic._value_1}_{audio_source}_{audio_analytics}_{rule}", + f"{uid}_{value_1}_{audio_source}_{audio_analytics}_{rule}", "Detected Sound", "binary_sensor", "sound", None, - msg.Message._value_1.Data.SimpleItem[0].Value == "true", + value_1.Data.SimpleItem[0].Value == "true", ) except (AttributeError, KeyError): return None @PARSERS.register("tns1:RuleEngine/FieldDetector/ObjectsInside") -# pylint: disable=protected-access async def async_parse_field_detector(uid: str, msg) -> Event | None: """Handle parsing event message. @@ -199,7 +198,8 @@ async def async_parse_field_detector(uid: str, msg) -> Event | None: video_source = "" video_analytics = "" rule = "" - for source in msg.Message._value_1.Source.SimpleItem: + value_1 = msg.Message._value_1 # pylint: disable=protected-access + for source in value_1.Source.SimpleItem: if source.Name == "VideoSourceConfigurationToken": video_source = _normalize_video_source(source.Value) if source.Name == "VideoAnalyticsConfigurationToken": @@ -208,12 +208,12 @@ async def async_parse_field_detector(uid: str, msg) -> Event | None: rule = source.Value evt = Event( - f"{uid}_{msg.Topic._value_1}_{video_source}_{video_analytics}_{rule}", + f"{uid}_{value_1}_{video_source}_{video_analytics}_{rule}", "Field Detection", "binary_sensor", "motion", None, - msg.Message._value_1.Data.SimpleItem[0].Value == "true", + value_1.Data.SimpleItem[0].Value == "true", ) return evt except (AttributeError, KeyError): @@ -221,7 +221,6 @@ async def async_parse_field_detector(uid: str, msg) -> Event | None: @PARSERS.register("tns1:RuleEngine/CellMotionDetector/Motion") -# pylint: disable=protected-access async def async_parse_cell_motion_detector(uid: str, msg) -> Event | None: """Handle parsing event message. @@ -231,7 +230,8 @@ async def async_parse_cell_motion_detector(uid: str, msg) -> Event | None: video_source = "" video_analytics = "" rule = "" - for source in msg.Message._value_1.Source.SimpleItem: + value_1 = msg.Message._value_1 # pylint: disable=protected-access + for source in value_1.Source.SimpleItem: if source.Name == "VideoSourceConfigurationToken": video_source = _normalize_video_source(source.Value) if source.Name == "VideoAnalyticsConfigurationToken": @@ -240,19 +240,18 @@ async def async_parse_cell_motion_detector(uid: str, msg) -> Event | None: rule = source.Value return Event( - f"{uid}_{msg.Topic._value_1}_{video_source}_{video_analytics}_{rule}", + f"{uid}_{value_1}_{video_source}_{video_analytics}_{rule}", "Cell Motion Detection", "binary_sensor", "motion", None, - msg.Message._value_1.Data.SimpleItem[0].Value == "true", + value_1.Data.SimpleItem[0].Value == "true", ) except (AttributeError, KeyError): return None @PARSERS.register("tns1:RuleEngine/MotionRegionDetector/Motion") -# pylint: disable=protected-access async def async_parse_motion_region_detector(uid: str, msg) -> Event | None: """Handle parsing event message. @@ -262,7 +261,8 @@ async def async_parse_motion_region_detector(uid: str, msg) -> Event | None: video_source = "" video_analytics = "" rule = "" - for source in msg.Message._value_1.Source.SimpleItem: + value_1 = msg.Message._value_1 # pylint: disable=protected-access + for source in value_1.Source.SimpleItem: if source.Name == "VideoSourceConfigurationToken": video_source = _normalize_video_source(source.Value) if source.Name == "VideoAnalyticsConfigurationToken": @@ -271,19 +271,18 @@ async def async_parse_motion_region_detector(uid: str, msg) -> Event | None: rule = source.Value return Event( - f"{uid}_{msg.Topic._value_1}_{video_source}_{video_analytics}_{rule}", + f"{uid}_{value_1}_{video_source}_{video_analytics}_{rule}", "Motion Region Detection", "binary_sensor", "motion", None, - msg.Message._value_1.Data.SimpleItem[0].Value in ["1", "true"], + value_1.Data.SimpleItem[0].Value in ["1", "true"], ) except (AttributeError, KeyError): return None @PARSERS.register("tns1:RuleEngine/TamperDetector/Tamper") -# pylint: disable=protected-access async def async_parse_tamper_detector(uid: str, msg) -> Event | None: """Handle parsing event message. @@ -293,7 +292,8 @@ async def async_parse_tamper_detector(uid: str, msg) -> Event | None: video_source = "" video_analytics = "" rule = "" - for source in msg.Message._value_1.Source.SimpleItem: + value_1 = msg.Message._value_1 # pylint: disable=protected-access + for source in value_1.Source.SimpleItem: if source.Name == "VideoSourceConfigurationToken": video_source = _normalize_video_source(source.Value) if source.Name == "VideoAnalyticsConfigurationToken": @@ -302,12 +302,12 @@ async def async_parse_tamper_detector(uid: str, msg) -> Event | None: rule = source.Value return Event( - f"{uid}_{msg.Topic._value_1}_{video_source}_{video_analytics}_{rule}", + f"{uid}_{value_1}_{video_source}_{video_analytics}_{rule}", "Tamper Detection", "binary_sensor", "problem", None, - msg.Message._value_1.Data.SimpleItem[0].Value == "true", + value_1.Data.SimpleItem[0].Value == "true", EntityCategory.DIAGNOSTIC, ) except (AttributeError, KeyError): @@ -315,7 +315,6 @@ async def async_parse_tamper_detector(uid: str, msg) -> Event | None: @PARSERS.register("tns1:RuleEngine/MyRuleDetector/DogCatDetect") -# pylint: disable=protected-access async def async_parse_dog_cat_detector(uid: str, msg) -> Event | None: """Handle parsing event message. @@ -323,24 +322,24 @@ async def async_parse_dog_cat_detector(uid: str, msg) -> Event | None: """ try: video_source = "" - for source in msg.Message._value_1.Source.SimpleItem: + value_1 = msg.Message._value_1 # pylint: disable=protected-access + for source in value_1.Source.SimpleItem: if source.Name == "Source": video_source = _normalize_video_source(source.Value) return Event( - f"{uid}_{msg.Topic._value_1}_{video_source}", + f"{uid}_{value_1}_{video_source}", "Pet Detection", "binary_sensor", "motion", None, - msg.Message._value_1.Data.SimpleItem[0].Value == "true", + value_1.Data.SimpleItem[0].Value == "true", ) except (AttributeError, KeyError): return None @PARSERS.register("tns1:RuleEngine/MyRuleDetector/VehicleDetect") -# pylint: disable=protected-access async def async_parse_vehicle_detector(uid: str, msg) -> Event | None: """Handle parsing event message. @@ -348,24 +347,24 @@ async def async_parse_vehicle_detector(uid: str, msg) -> Event | None: """ try: video_source = "" - for source in msg.Message._value_1.Source.SimpleItem: + value_1 = msg.Message._value_1 # pylint: disable=protected-access + for source in value_1.Source.SimpleItem: if source.Name == "Source": video_source = _normalize_video_source(source.Value) return Event( - f"{uid}_{msg.Topic._value_1}_{video_source}", + f"{uid}_{value_1}_{video_source}", "Vehicle Detection", "binary_sensor", "motion", None, - msg.Message._value_1.Data.SimpleItem[0].Value == "true", + value_1.Data.SimpleItem[0].Value == "true", ) except (AttributeError, KeyError): return None @PARSERS.register("tns1:RuleEngine/MyRuleDetector/PeopleDetect") -# pylint: disable=protected-access async def async_parse_person_detector(uid: str, msg) -> Event | None: """Handle parsing event message. @@ -373,24 +372,24 @@ async def async_parse_person_detector(uid: str, msg) -> Event | None: """ try: video_source = "" - for source in msg.Message._value_1.Source.SimpleItem: + value_1 = msg.Message._value_1 # pylint: disable=protected-access + for source in value_1.Source.SimpleItem: if source.Name == "Source": video_source = _normalize_video_source(source.Value) return Event( - f"{uid}_{msg.Topic._value_1}_{video_source}", + f"{uid}_{value_1}_{video_source}", "Person Detection", "binary_sensor", "motion", None, - msg.Message._value_1.Data.SimpleItem[0].Value == "true", + value_1.Data.SimpleItem[0].Value == "true", ) except (AttributeError, KeyError): return None @PARSERS.register("tns1:RuleEngine/MyRuleDetector/FaceDetect") -# pylint: disable=protected-access async def async_parse_face_detector(uid: str, msg) -> Event | None: """Handle parsing event message. @@ -398,24 +397,24 @@ async def async_parse_face_detector(uid: str, msg) -> Event | None: """ try: video_source = "" - for source in msg.Message._value_1.Source.SimpleItem: + value_1 = msg.Message._value_1 # pylint: disable=protected-access + for source in value_1.Source.SimpleItem: if source.Name == "Source": video_source = _normalize_video_source(source.Value) return Event( - f"{uid}_{msg.Topic._value_1}_{video_source}", + f"{uid}_{value_1}_{video_source}", "Face Detection", "binary_sensor", "motion", None, - msg.Message._value_1.Data.SimpleItem[0].Value == "true", + value_1.Data.SimpleItem[0].Value == "true", ) except (AttributeError, KeyError): return None @PARSERS.register("tns1:RuleEngine/MyRuleDetector/Visitor") -# pylint: disable=protected-access async def async_parse_visitor_detector(uid: str, msg) -> Event | None: """Handle parsing event message. @@ -423,80 +422,81 @@ async def async_parse_visitor_detector(uid: str, msg) -> Event | None: """ try: video_source = "" - for source in msg.Message._value_1.Source.SimpleItem: + value_1 = msg.Message._value_1 # pylint: disable=protected-access + for source in value_1.Source.SimpleItem: if source.Name == "Source": video_source = _normalize_video_source(source.Value) return Event( - f"{uid}_{msg.Topic._value_1}_{video_source}", + f"{uid}_{value_1}_{video_source}", "Visitor Detection", "binary_sensor", "occupancy", None, - msg.Message._value_1.Data.SimpleItem[0].Value == "true", + value_1.Data.SimpleItem[0].Value == "true", ) except (AttributeError, KeyError): return None @PARSERS.register("tns1:Device/Trigger/DigitalInput") -# pylint: disable=protected-access async def async_parse_digital_input(uid: str, msg) -> Event | None: """Handle parsing event message. Topic: tns1:Device/Trigger/DigitalInput """ try: - source = msg.Message._value_1.Source.SimpleItem[0].Value + value_1 = msg.Message._value_1 # pylint: disable=protected-access + source = value_1.Source.SimpleItem[0].Value return Event( - f"{uid}_{msg.Topic._value_1}_{source}", + f"{uid}_{value_1}_{source}", "Digital Input", "binary_sensor", None, None, - msg.Message._value_1.Data.SimpleItem[0].Value == "true", + value_1.Data.SimpleItem[0].Value == "true", ) except (AttributeError, KeyError): return None @PARSERS.register("tns1:Device/Trigger/Relay") -# pylint: disable=protected-access async def async_parse_relay(uid: str, msg) -> Event | None: """Handle parsing event message. Topic: tns1:Device/Trigger/Relay """ try: - source = msg.Message._value_1.Source.SimpleItem[0].Value + value_1 = msg.Message._value_1 # pylint: disable=protected-access + source = value_1.Source.SimpleItem[0].Value return Event( - f"{uid}_{msg.Topic._value_1}_{source}", + f"{uid}_{value_1}_{source}", "Relay Triggered", "binary_sensor", None, None, - msg.Message._value_1.Data.SimpleItem[0].Value == "active", + value_1.Data.SimpleItem[0].Value == "active", ) except (AttributeError, KeyError): return None @PARSERS.register("tns1:Device/HardwareFailure/StorageFailure") -# pylint: disable=protected-access async def async_parse_storage_failure(uid: str, msg) -> Event | None: """Handle parsing event message. Topic: tns1:Device/HardwareFailure/StorageFailure """ try: - source = msg.Message._value_1.Source.SimpleItem[0].Value + value_1 = msg.Message._value_1 # pylint: disable=protected-access + source = value_1.Source.SimpleItem[0].Value return Event( - f"{uid}_{msg.Topic._value_1}_{source}", + f"{uid}_{value_1}_{source}", "Storage Failure", "binary_sensor", "problem", None, - msg.Message._value_1.Data.SimpleItem[0].Value == "true", + value_1.Data.SimpleItem[0].Value == "true", EntityCategory.DIAGNOSTIC, ) except (AttributeError, KeyError): @@ -504,19 +504,19 @@ async def async_parse_storage_failure(uid: str, msg) -> Event | None: @PARSERS.register("tns1:Monitoring/ProcessorUsage") -# pylint: disable=protected-access async def async_parse_processor_usage(uid: str, msg) -> Event | None: """Handle parsing event message. Topic: tns1:Monitoring/ProcessorUsage """ try: - usage = float(msg.Message._value_1.Data.SimpleItem[0].Value) + value_1 = msg.Message._value_1 # pylint: disable=protected-access + usage = float(value_1.Data.SimpleItem[0].Value) if usage <= 1: usage *= 100 return Event( - f"{uid}_{msg.Topic._value_1}", + f"{uid}_{value_1}", "Processor Usage", "sensor", None, @@ -529,18 +529,16 @@ async def async_parse_processor_usage(uid: str, msg) -> Event | None: @PARSERS.register("tns1:Monitoring/OperatingTime/LastReboot") -# pylint: disable=protected-access async def async_parse_last_reboot(uid: str, msg) -> Event | None: """Handle parsing event message. Topic: tns1:Monitoring/OperatingTime/LastReboot """ try: - date_time = local_datetime_or_none( - msg.Message._value_1.Data.SimpleItem[0].Value - ) + value_1 = msg.Message._value_1 # pylint: disable=protected-access + date_time = local_datetime_or_none(value_1.Data.SimpleItem[0].Value) return Event( - f"{uid}_{msg.Topic._value_1}", + f"{uid}_{value_1}", "Last Reboot", "sensor", "timestamp", @@ -553,18 +551,16 @@ async def async_parse_last_reboot(uid: str, msg) -> Event | None: @PARSERS.register("tns1:Monitoring/OperatingTime/LastReset") -# pylint: disable=protected-access async def async_parse_last_reset(uid: str, msg) -> Event | None: """Handle parsing event message. Topic: tns1:Monitoring/OperatingTime/LastReset """ try: - date_time = local_datetime_or_none( - msg.Message._value_1.Data.SimpleItem[0].Value - ) + value_1 = msg.Message._value_1 # pylint: disable=protected-access + date_time = local_datetime_or_none(value_1.Data.SimpleItem[0].Value) return Event( - f"{uid}_{msg.Topic._value_1}", + f"{uid}_{value_1}", "Last Reset", "sensor", "timestamp", @@ -578,7 +574,6 @@ async def async_parse_last_reset(uid: str, msg) -> Event | None: @PARSERS.register("tns1:Monitoring/Backup/Last") -# pylint: disable=protected-access async def async_parse_backup_last(uid: str, msg) -> Event | None: """Handle parsing event message. @@ -586,11 +581,10 @@ async def async_parse_backup_last(uid: str, msg) -> Event | None: """ try: - date_time = local_datetime_or_none( - msg.Message._value_1.Data.SimpleItem[0].Value - ) + value_1 = msg.Message._value_1 # pylint: disable=protected-access + date_time = local_datetime_or_none(value_1.Data.SimpleItem[0].Value) return Event( - f"{uid}_{msg.Topic._value_1}", + f"{uid}_{value_1}", "Last Backup", "sensor", "timestamp", @@ -604,18 +598,16 @@ async def async_parse_backup_last(uid: str, msg) -> Event | None: @PARSERS.register("tns1:Monitoring/OperatingTime/LastClockSynchronization") -# pylint: disable=protected-access async def async_parse_last_clock_sync(uid: str, msg) -> Event | None: """Handle parsing event message. Topic: tns1:Monitoring/OperatingTime/LastClockSynchronization """ try: - date_time = local_datetime_or_none( - msg.Message._value_1.Data.SimpleItem[0].Value - ) + value_1 = msg.Message._value_1 # pylint: disable=protected-access + date_time = local_datetime_or_none(value_1.Data.SimpleItem[0].Value) return Event( - f"{uid}_{msg.Topic._value_1}", + f"{uid}_{value_1}", "Last Clock Synchronization", "sensor", "timestamp", @@ -629,7 +621,6 @@ async def async_parse_last_clock_sync(uid: str, msg) -> Event | None: @PARSERS.register("tns1:RecordingConfig/JobState") -# pylint: disable=protected-access async def async_parse_jobstate(uid: str, msg) -> Event | None: """Handle parsing event message. @@ -637,14 +628,15 @@ async def async_parse_jobstate(uid: str, msg) -> Event | None: """ try: - source = msg.Message._value_1.Source.SimpleItem[0].Value + value_1 = msg.Message._value_1 # pylint: disable=protected-access + source = value_1.Source.SimpleItem[0].Value return Event( - f"{uid}_{msg.Topic._value_1}_{source}", + f"{uid}_{value_1}_{source}", "Recording Job State", "binary_sensor", None, None, - msg.Message._value_1.Data.SimpleItem[0].Value == "Active", + value_1.Data.SimpleItem[0].Value == "Active", EntityCategory.DIAGNOSTIC, ) except (AttributeError, KeyError): @@ -652,7 +644,6 @@ async def async_parse_jobstate(uid: str, msg) -> Event | None: @PARSERS.register("tns1:RuleEngine/LineDetector/Crossed") -# pylint: disable=protected-access async def async_parse_linedetector_crossed(uid: str, msg) -> Event | None: """Handle parsing event message. @@ -662,7 +653,8 @@ async def async_parse_linedetector_crossed(uid: str, msg) -> Event | None: video_source = "" video_analytics = "" rule = "" - for source in msg.Message._value_1.Source.SimpleItem: + value_1 = msg.Message._value_1 # pylint: disable=protected-access + for source in value_1.Source.SimpleItem: if source.Name == "VideoSourceConfigurationToken": video_source = source.Value if source.Name == "VideoAnalyticsConfigurationToken": @@ -671,12 +663,12 @@ async def async_parse_linedetector_crossed(uid: str, msg) -> Event | None: rule = source.Value return Event( - f"{uid}_{msg.Topic._value_1}_{video_source}_{video_analytics}_{rule}", + f"{uid}_{value_1}_{video_source}_{video_analytics}_{rule}", "Line Detector Crossed", "sensor", None, None, - msg.Message._value_1.Data.SimpleItem[0].Value, + value_1.Data.SimpleItem[0].Value, EntityCategory.DIAGNOSTIC, ) except (AttributeError, KeyError): @@ -684,7 +676,6 @@ async def async_parse_linedetector_crossed(uid: str, msg) -> Event | None: @PARSERS.register("tns1:RuleEngine/CountAggregation/Counter") -# pylint: disable=protected-access async def async_parse_count_aggregation_counter(uid: str, msg) -> Event | None: """Handle parsing event message. @@ -694,7 +685,8 @@ async def async_parse_count_aggregation_counter(uid: str, msg) -> Event | None: video_source = "" video_analytics = "" rule = "" - for source in msg.Message._value_1.Source.SimpleItem: + value_1 = msg.Message._value_1 # pylint: disable=protected-access + for source in value_1.Source.SimpleItem: if source.Name == "VideoSourceConfigurationToken": video_source = _normalize_video_source(source.Value) if source.Name == "VideoAnalyticsConfigurationToken": @@ -703,12 +695,12 @@ async def async_parse_count_aggregation_counter(uid: str, msg) -> Event | None: rule = source.Value return Event( - f"{uid}_{msg.Topic._value_1}_{video_source}_{video_analytics}_{rule}", + f"{uid}_{value_1}_{video_source}_{video_analytics}_{rule}", "Count Aggregation Counter", "sensor", None, None, - msg.Message._value_1.Data.SimpleItem[0].Value, + value_1.Data.SimpleItem[0].Value, EntityCategory.DIAGNOSTIC, ) except (AttributeError, KeyError): diff --git a/homeassistant/components/opencv/manifest.json b/homeassistant/components/opencv/manifest.json index da541974b46e92..3c484385934267 100644 --- a/homeassistant/components/opencv/manifest.json +++ b/homeassistant/components/opencv/manifest.json @@ -4,5 +4,5 @@ "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/opencv", "iot_class": "local_push", - "requirements": ["numpy==1.23.2", "opencv-python-headless==4.6.0.66"] + "requirements": ["numpy==1.26.0", "opencv-python-headless==4.6.0.66"] } diff --git a/homeassistant/components/openhome/media_player.py b/homeassistant/components/openhome/media_player.py index efc6ab37f21aa9..51d7774a2fb865 100644 --- a/homeassistant/components/openhome/media_player.py +++ b/homeassistant/components/openhome/media_player.py @@ -102,26 +102,23 @@ async def wrapper( class OpenhomeDevice(MediaPlayerEntity): """Representation of an Openhome device.""" + _attr_supported_features = SUPPORT_OPENHOME + _attr_state = MediaPlayerState.PLAYING + _attr_available = True + def __init__(self, hass, device): """Initialise the Openhome device.""" self.hass = hass self._device = device self._attr_unique_id = device.uuid() - self._attr_supported_features = SUPPORT_OPENHOME self._source_index = {} - self._attr_state = MediaPlayerState.PLAYING - self._attr_available = True - - @property - def device_info(self): - """Return a device description for device registry.""" - return DeviceInfo( + self._attr_device_info = DeviceInfo( identifiers={ - (DOMAIN, self._device.uuid()), + (DOMAIN, device.uuid()), }, - manufacturer=self._device.manufacturer(), - model=self._device.model_name(), - name=self._device.friendly_name(), + manufacturer=device.manufacturer(), + model=device.model_name(), + name=device.friendly_name(), ) async def async_update(self) -> None: diff --git a/homeassistant/components/openhome/update.py b/homeassistant/components/openhome/update.py index 9013e50030fbcc..691776e4dfd6cc 100644 --- a/homeassistant/components/openhome/update.py +++ b/homeassistant/components/openhome/update.py @@ -54,17 +54,13 @@ def __init__(self, device): """Initialize a Linn DS update entity.""" self._device = device self._attr_unique_id = f"{device.uuid()}-update" - - @property - def device_info(self): - """Return a device description for device registry.""" - return DeviceInfo( + self._attr_device_info = DeviceInfo( identifiers={ - (DOMAIN, self._device.uuid()), + (DOMAIN, device.uuid()), }, - manufacturer=self._device.manufacturer(), - model=self._device.model_name(), - name=self._device.friendly_name(), + manufacturer=device.manufacturer(), + model=device.model_name(), + name=device.friendly_name(), ) async def async_update(self) -> None: diff --git a/homeassistant/components/opentherm_gw/__init__.py b/homeassistant/components/opentherm_gw/__init__.py index 0b8d4693cb817d..cd8b98880d55dc 100644 --- a/homeassistant/components/opentherm_gw/__init__.py +++ b/homeassistant/components/opentherm_gw/__init__.py @@ -163,8 +163,7 @@ def register_services(hass: HomeAssistant) -> None: vol.Required(ATTR_GW_ID): vol.All( cv.string, vol.In(hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS]) ), - # pylint: disable=unnecessary-lambda - vol.Optional(ATTR_DATE, default=lambda: date.today()): cv.date, + vol.Optional(ATTR_DATE, default=date.today): cv.date, vol.Optional(ATTR_TIME, default=lambda: datetime.now().time()): cv.time, } ) diff --git a/homeassistant/components/opentherm_gw/binary_sensor.py b/homeassistant/components/opentherm_gw/binary_sensor.py index 2501d00c2eb835..d6aa5a3b700813 100644 --- a/homeassistant/components/opentherm_gw/binary_sensor.py +++ b/homeassistant/components/opentherm_gw/binary_sensor.py @@ -1,12 +1,10 @@ """Support for OpenTherm Gateway binary sensors.""" import logging -from pprint import pformat from homeassistant.components.binary_sensor import ENTITY_ID_FORMAT, BinarySensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import async_generate_entity_id @@ -17,7 +15,6 @@ BINARY_SENSOR_INFO, DATA_GATEWAYS, DATA_OPENTHERM_GW, - DEPRECATED_BINARY_SENSOR_SOURCE_LOOKUP, TRANSLATE_SOURCE, ) @@ -31,9 +28,7 @@ async def async_setup_entry( ) -> None: """Set up the OpenTherm Gateway binary sensors.""" sensors = [] - deprecated_sensors = [] gw_dev = hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][config_entry.data[CONF_ID]] - ent_reg = er.async_get(hass) for var, info in BINARY_SENSOR_INFO.items(): device_class = info[0] friendly_name_format = info[1] @@ -50,36 +45,6 @@ async def async_setup_entry( ) ) - old_style_entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, f"{var}_{gw_dev.gw_id}", hass=gw_dev.hass - ) - old_ent = ent_reg.async_get(old_style_entity_id) - if old_ent and old_ent.config_entry_id == config_entry.entry_id: - if old_ent.disabled: - ent_reg.async_remove(old_style_entity_id) - else: - deprecated_sensors.append( - DeprecatedOpenThermBinarySensor( - gw_dev, - var, - device_class, - friendly_name_format, - ) - ) - - sensors.extend(deprecated_sensors) - - if deprecated_sensors: - _LOGGER.warning( - ( - "The following binary_sensor entities are deprecated and may " - "no longer behave as expected. They will be removed in a " - "future version. You can force removal of these entities by " - "disabling them and restarting Home Assistant.\n%s" - ), - pformat([s.entity_id for s in deprecated_sensors]), - ) - async_add_entities(sensors) @@ -87,6 +52,7 @@ class OpenThermBinarySensor(BinarySensorEntity): """Represent an OpenTherm Gateway binary sensor.""" _attr_should_poll = False + _attr_entity_registry_enabled_default = False def __init__(self, gw_dev, var, source, device_class, friendly_name_format): """Initialize the binary sensor.""" @@ -96,96 +62,42 @@ def __init__(self, gw_dev, var, source, device_class, friendly_name_format): self._gateway = gw_dev self._var = var self._source = source - self._state = None - self._device_class = device_class + self._attr_device_class = device_class if TRANSLATE_SOURCE[source] is not None: friendly_name_format = ( f"{friendly_name_format} ({TRANSLATE_SOURCE[source]})" ) - self._friendly_name = friendly_name_format.format(gw_dev.name) + self._attr_name = friendly_name_format.format(gw_dev.name) self._unsub_updates = None + self._attr_unique_id = f"{gw_dev.gw_id}-{source}-{var}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, gw_dev.gw_id)}, + manufacturer="Schelte Bron", + model="OpenTherm Gateway", + name=gw_dev.name, + sw_version=gw_dev.gw_version, + ) async def async_added_to_hass(self) -> None: """Subscribe to updates from the component.""" - _LOGGER.debug("Added OpenTherm Gateway binary sensor %s", self._friendly_name) + _LOGGER.debug("Added OpenTherm Gateway binary sensor %s", self._attr_name) self._unsub_updates = async_dispatcher_connect( self.hass, self._gateway.update_signal, self.receive_report ) async def async_will_remove_from_hass(self) -> None: """Unsubscribe from updates from the component.""" - _LOGGER.debug( - "Removing OpenTherm Gateway binary sensor %s", self._friendly_name - ) + _LOGGER.debug("Removing OpenTherm Gateway binary sensor %s", self._attr_name) self._unsub_updates() @property def available(self): """Return availability of the sensor.""" - return self._state is not None - - @property - def entity_registry_enabled_default(self): - """Disable binary_sensors by default.""" - return False + return self._attr_is_on is not None @callback def receive_report(self, status): """Handle status updates from the component.""" state = status[self._source].get(self._var) - self._state = None if state is None else bool(state) + self._attr_is_on = None if state is None else bool(state) self.async_write_ha_state() - - @property - def name(self): - """Return the friendly name.""" - return self._friendly_name - - @property - def device_info(self) -> DeviceInfo: - """Return device info.""" - return DeviceInfo( - identifiers={(DOMAIN, self._gateway.gw_id)}, - manufacturer="Schelte Bron", - model="OpenTherm Gateway", - name=self._gateway.name, - sw_version=self._gateway.gw_version, - ) - - @property - def unique_id(self): - """Return a unique ID.""" - return f"{self._gateway.gw_id}-{self._source}-{self._var}" - - @property - def is_on(self): - """Return true if the binary sensor is on.""" - return self._state - - @property - def device_class(self): - """Return the class of this device.""" - return self._device_class - - -class DeprecatedOpenThermBinarySensor(OpenThermBinarySensor): - """Represent a deprecated OpenTherm Gateway Binary Sensor.""" - - # pylint: disable=super-init-not-called - def __init__(self, gw_dev, var, device_class, friendly_name_format): - """Initialize the binary sensor.""" - self.entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, f"{var}_{gw_dev.gw_id}", hass=gw_dev.hass - ) - self._gateway = gw_dev - self._var = var - self._source = DEPRECATED_BINARY_SENSOR_SOURCE_LOOKUP[var] - self._state = None - self._device_class = device_class - self._friendly_name = friendly_name_format.format(gw_dev.name) - self._unsub_updates = None - - @property - def unique_id(self): - """Return a unique ID.""" - return f"{self._gateway.gw_id}-{self._var}" diff --git a/homeassistant/components/opentherm_gw/climate.py b/homeassistant/components/opentherm_gw/climate.py index b34239c933afd8..bcad621eb82d6b 100644 --- a/homeassistant/components/opentherm_gw/climate.py +++ b/homeassistant/components/opentherm_gw/climate.py @@ -70,6 +70,20 @@ class OpenThermClimate(ClimateEntity): ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE ) _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_available = False + _attr_hvac_modes = [] + _attr_preset_modes = [] + _attr_min_temp = 1 + _attr_max_temp = 30 + _hvac_mode = HVACMode.HEAT + _current_temperature: float | None = None + _new_target_temperature: float | None = None + _target_temperature: float | None = None + _away_mode_a: int | None = None + _away_mode_b: int | None = None + _away_state_a = False + _away_state_b = False + _current_operation: HVACAction | None = None def __init__(self, gw_dev, options): """Initialize the device.""" @@ -78,22 +92,21 @@ def __init__(self, gw_dev, options): ENTITY_ID_FORMAT, gw_dev.gw_id, hass=gw_dev.hass ) self.friendly_name = gw_dev.name + self._attr_name = self.friendly_name self.floor_temp = options.get(CONF_FLOOR_TEMP, DEFAULT_FLOOR_TEMP) self.temp_read_precision = options.get(CONF_READ_PRECISION) self.temp_set_precision = options.get(CONF_SET_PRECISION) self.temporary_ovrd_mode = options.get(CONF_TEMPORARY_OVRD_MODE, True) - self._available = False - self._current_operation: HVACAction | None = None - self._current_temperature = None - self._hvac_mode = HVACMode.HEAT - self._new_target_temperature = None - self._target_temperature = None - self._away_mode_a = None - self._away_mode_b = None - self._away_state_a = False - self._away_state_b = False self._unsub_options = None self._unsub_updates = None + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, gw_dev.gw_id)}, + manufacturer="Schelte Bron", + model="OpenTherm Gateway", + name=gw_dev.name, + sw_version=gw_dev.gw_version, + ) + self._attr_unique_id = gw_dev.gw_id @callback def update_options(self, entry): @@ -123,7 +136,7 @@ async def async_will_remove_from_hass(self) -> None: @callback def receive_report(self, status): """Receive and handle a new report from the Gateway.""" - self._available = status != gw_vars.DEFAULT_STATUS + self._attr_available = status != gw_vars.DEFAULT_STATUS ch_active = status[gw_vars.BOILER].get(gw_vars.DATA_SLAVE_CH_ACTIVE) flame_on = status[gw_vars.BOILER].get(gw_vars.DATA_SLAVE_FLAME_ON) cooling_active = status[gw_vars.BOILER].get(gw_vars.DATA_SLAVE_COOLING_ACTIVE) @@ -171,32 +184,6 @@ def receive_report(self, status): ) self.async_write_ha_state() - @property - def available(self): - """Return availability of the sensor.""" - return self._available - - @property - def name(self): - """Return the friendly name.""" - return self.friendly_name - - @property - def device_info(self) -> DeviceInfo: - """Return device info.""" - return DeviceInfo( - identifiers={(DOMAIN, self._gateway.gw_id)}, - manufacturer="Schelte Bron", - model="OpenTherm Gateway", - name=self._gateway.name, - sw_version=self._gateway.gw_version, - ) - - @property - def unique_id(self): - """Return a unique ID.""" - return self._gateway.gw_id - @property def precision(self): """Return the precision of the system.""" @@ -216,11 +203,6 @@ def hvac_mode(self) -> HVACMode: """Return current HVAC mode.""" return self._hvac_mode - @property - def hvac_modes(self) -> list[HVACMode]: - """Return available HVAC modes.""" - return [] - def set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set the HVAC mode.""" _LOGGER.warning("Changing HVAC mode is not supported") @@ -259,11 +241,6 @@ def preset_mode(self): return PRESET_AWAY return PRESET_NONE - @property - def preset_modes(self): - """Available preset modes to set.""" - return [] - def set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode.""" _LOGGER.warning("Changing preset mode is not supported") @@ -278,13 +255,3 @@ async def async_set_temperature(self, **kwargs: Any) -> None: temp, self.temporary_ovrd_mode ) self.async_write_ha_state() - - @property - def min_temp(self): - """Return the minimum temperature.""" - return 1 - - @property - def max_temp(self): - """Return the maximum temperature.""" - return 30 diff --git a/homeassistant/components/opentherm_gw/const.py b/homeassistant/components/opentherm_gw/const.py index 1532b7877406a8..a6c75c171136ae 100644 --- a/homeassistant/components/opentherm_gw/const.py +++ b/homeassistant/components/opentherm_gw/const.py @@ -535,106 +535,3 @@ [gw_vars.OTGW], ], } - -DEPRECATED_BINARY_SENSOR_SOURCE_LOOKUP = { - gw_vars.DATA_MASTER_CH_ENABLED: gw_vars.THERMOSTAT, - gw_vars.DATA_MASTER_DHW_ENABLED: gw_vars.THERMOSTAT, - gw_vars.DATA_MASTER_OTC_ENABLED: gw_vars.THERMOSTAT, - gw_vars.DATA_MASTER_CH2_ENABLED: gw_vars.THERMOSTAT, - gw_vars.DATA_SLAVE_FAULT_IND: gw_vars.BOILER, - gw_vars.DATA_SLAVE_CH_ACTIVE: gw_vars.BOILER, - gw_vars.DATA_SLAVE_DHW_ACTIVE: gw_vars.BOILER, - gw_vars.DATA_SLAVE_FLAME_ON: gw_vars.BOILER, - gw_vars.DATA_SLAVE_COOLING_ACTIVE: gw_vars.BOILER, - gw_vars.DATA_SLAVE_CH2_ACTIVE: gw_vars.BOILER, - gw_vars.DATA_SLAVE_DIAG_IND: gw_vars.BOILER, - gw_vars.DATA_SLAVE_DHW_PRESENT: gw_vars.BOILER, - gw_vars.DATA_SLAVE_CONTROL_TYPE: gw_vars.BOILER, - gw_vars.DATA_SLAVE_COOLING_SUPPORTED: gw_vars.BOILER, - gw_vars.DATA_SLAVE_DHW_CONFIG: gw_vars.BOILER, - gw_vars.DATA_SLAVE_MASTER_LOW_OFF_PUMP: gw_vars.BOILER, - gw_vars.DATA_SLAVE_CH2_PRESENT: gw_vars.BOILER, - gw_vars.DATA_SLAVE_SERVICE_REQ: gw_vars.BOILER, - gw_vars.DATA_SLAVE_REMOTE_RESET: gw_vars.BOILER, - gw_vars.DATA_SLAVE_LOW_WATER_PRESS: gw_vars.BOILER, - gw_vars.DATA_SLAVE_GAS_FAULT: gw_vars.BOILER, - gw_vars.DATA_SLAVE_AIR_PRESS_FAULT: gw_vars.BOILER, - gw_vars.DATA_SLAVE_WATER_OVERTEMP: gw_vars.BOILER, - gw_vars.DATA_REMOTE_TRANSFER_DHW: gw_vars.BOILER, - gw_vars.DATA_REMOTE_TRANSFER_MAX_CH: gw_vars.BOILER, - gw_vars.DATA_REMOTE_RW_DHW: gw_vars.BOILER, - gw_vars.DATA_REMOTE_RW_MAX_CH: gw_vars.BOILER, - gw_vars.DATA_ROVRD_MAN_PRIO: gw_vars.THERMOSTAT, - gw_vars.DATA_ROVRD_AUTO_PRIO: gw_vars.THERMOSTAT, - gw_vars.OTGW_GPIO_A_STATE: gw_vars.OTGW, - gw_vars.OTGW_GPIO_B_STATE: gw_vars.OTGW, - gw_vars.OTGW_IGNORE_TRANSITIONS: gw_vars.OTGW, - gw_vars.OTGW_OVRD_HB: gw_vars.OTGW, -} - -DEPRECATED_SENSOR_SOURCE_LOOKUP = { - gw_vars.DATA_CONTROL_SETPOINT: gw_vars.BOILER, - gw_vars.DATA_MASTER_MEMBERID: gw_vars.THERMOSTAT, - gw_vars.DATA_SLAVE_MEMBERID: gw_vars.BOILER, - gw_vars.DATA_SLAVE_OEM_FAULT: gw_vars.BOILER, - gw_vars.DATA_COOLING_CONTROL: gw_vars.BOILER, - gw_vars.DATA_CONTROL_SETPOINT_2: gw_vars.BOILER, - gw_vars.DATA_ROOM_SETPOINT_OVRD: gw_vars.THERMOSTAT, - gw_vars.DATA_SLAVE_MAX_RELATIVE_MOD: gw_vars.BOILER, - gw_vars.DATA_SLAVE_MAX_CAPACITY: gw_vars.BOILER, - gw_vars.DATA_SLAVE_MIN_MOD_LEVEL: gw_vars.BOILER, - gw_vars.DATA_ROOM_SETPOINT: gw_vars.THERMOSTAT, - gw_vars.DATA_REL_MOD_LEVEL: gw_vars.BOILER, - gw_vars.DATA_CH_WATER_PRESS: gw_vars.BOILER, - gw_vars.DATA_DHW_FLOW_RATE: gw_vars.BOILER, - gw_vars.DATA_ROOM_SETPOINT_2: gw_vars.THERMOSTAT, - gw_vars.DATA_ROOM_TEMP: gw_vars.THERMOSTAT, - gw_vars.DATA_CH_WATER_TEMP: gw_vars.BOILER, - gw_vars.DATA_DHW_TEMP: gw_vars.BOILER, - gw_vars.DATA_OUTSIDE_TEMP: gw_vars.THERMOSTAT, - gw_vars.DATA_RETURN_WATER_TEMP: gw_vars.BOILER, - gw_vars.DATA_SOLAR_STORAGE_TEMP: gw_vars.BOILER, - gw_vars.DATA_SOLAR_COLL_TEMP: gw_vars.BOILER, - gw_vars.DATA_CH_WATER_TEMP_2: gw_vars.BOILER, - gw_vars.DATA_DHW_TEMP_2: gw_vars.BOILER, - gw_vars.DATA_EXHAUST_TEMP: gw_vars.BOILER, - gw_vars.DATA_SLAVE_DHW_MAX_SETP: gw_vars.BOILER, - gw_vars.DATA_SLAVE_DHW_MIN_SETP: gw_vars.BOILER, - gw_vars.DATA_SLAVE_CH_MAX_SETP: gw_vars.BOILER, - gw_vars.DATA_SLAVE_CH_MIN_SETP: gw_vars.BOILER, - gw_vars.DATA_DHW_SETPOINT: gw_vars.BOILER, - gw_vars.DATA_MAX_CH_SETPOINT: gw_vars.BOILER, - gw_vars.DATA_OEM_DIAG: gw_vars.BOILER, - gw_vars.DATA_TOTAL_BURNER_STARTS: gw_vars.BOILER, - gw_vars.DATA_CH_PUMP_STARTS: gw_vars.BOILER, - gw_vars.DATA_DHW_PUMP_STARTS: gw_vars.BOILER, - gw_vars.DATA_DHW_BURNER_STARTS: gw_vars.BOILER, - gw_vars.DATA_TOTAL_BURNER_HOURS: gw_vars.BOILER, - gw_vars.DATA_CH_PUMP_HOURS: gw_vars.BOILER, - gw_vars.DATA_DHW_PUMP_HOURS: gw_vars.BOILER, - gw_vars.DATA_DHW_BURNER_HOURS: gw_vars.BOILER, - gw_vars.DATA_MASTER_OT_VERSION: gw_vars.THERMOSTAT, - gw_vars.DATA_SLAVE_OT_VERSION: gw_vars.BOILER, - gw_vars.DATA_MASTER_PRODUCT_TYPE: gw_vars.THERMOSTAT, - gw_vars.DATA_MASTER_PRODUCT_VERSION: gw_vars.THERMOSTAT, - gw_vars.DATA_SLAVE_PRODUCT_TYPE: gw_vars.BOILER, - gw_vars.DATA_SLAVE_PRODUCT_VERSION: gw_vars.BOILER, - gw_vars.OTGW_MODE: gw_vars.OTGW, - gw_vars.OTGW_DHW_OVRD: gw_vars.OTGW, - gw_vars.OTGW_ABOUT: gw_vars.OTGW, - gw_vars.OTGW_BUILD: gw_vars.OTGW, - gw_vars.OTGW_CLOCKMHZ: gw_vars.OTGW, - gw_vars.OTGW_LED_A: gw_vars.OTGW, - gw_vars.OTGW_LED_B: gw_vars.OTGW, - gw_vars.OTGW_LED_C: gw_vars.OTGW, - gw_vars.OTGW_LED_D: gw_vars.OTGW, - gw_vars.OTGW_LED_E: gw_vars.OTGW, - gw_vars.OTGW_LED_F: gw_vars.OTGW, - gw_vars.OTGW_GPIO_A: gw_vars.OTGW, - gw_vars.OTGW_GPIO_B: gw_vars.OTGW, - gw_vars.OTGW_SB_TEMP: gw_vars.OTGW, - gw_vars.OTGW_SETP_OVRD_MODE: gw_vars.OTGW, - gw_vars.OTGW_SMART_PWR: gw_vars.OTGW, - gw_vars.OTGW_THRM_DETECT: gw_vars.OTGW, - gw_vars.OTGW_VREF: gw_vars.OTGW, -} diff --git a/homeassistant/components/opentherm_gw/sensor.py b/homeassistant/components/opentherm_gw/sensor.py index b219969e71ae1b..09fbb0ef6ee9ea 100644 --- a/homeassistant/components/opentherm_gw/sensor.py +++ b/homeassistant/components/opentherm_gw/sensor.py @@ -1,25 +1,17 @@ """Support for OpenTherm Gateway sensors.""" import logging -from pprint import pformat from homeassistant.components.sensor import ENTITY_ID_FORMAT, SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import DOMAIN -from .const import ( - DATA_GATEWAYS, - DATA_OPENTHERM_GW, - DEPRECATED_SENSOR_SOURCE_LOOKUP, - SENSOR_INFO, - TRANSLATE_SOURCE, -) +from .const import DATA_GATEWAYS, DATA_OPENTHERM_GW, SENSOR_INFO, TRANSLATE_SOURCE _LOGGER = logging.getLogger(__name__) @@ -31,9 +23,7 @@ async def async_setup_entry( ) -> None: """Set up the OpenTherm Gateway sensors.""" sensors = [] - deprecated_sensors = [] gw_dev = hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][config_entry.data[CONF_ID]] - ent_reg = er.async_get(hass) for var, info in SENSOR_INFO.items(): device_class = info[0] unit = info[1] @@ -52,37 +42,6 @@ async def async_setup_entry( ) ) - old_style_entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, f"{var}_{gw_dev.gw_id}", hass=gw_dev.hass - ) - old_ent = ent_reg.async_get(old_style_entity_id) - if old_ent and old_ent.config_entry_id == config_entry.entry_id: - if old_ent.disabled: - ent_reg.async_remove(old_style_entity_id) - else: - deprecated_sensors.append( - DeprecatedOpenThermSensor( - gw_dev, - var, - device_class, - unit, - friendly_name_format, - ) - ) - - sensors.extend(deprecated_sensors) - - if deprecated_sensors: - _LOGGER.warning( - ( - "The following sensor entities are deprecated and may no " - "longer behave as expected. They will be removed in a future " - "version. You can force removal of these entities by disabling " - "them and restarting Home Assistant.\n%s" - ), - pformat([s.entity_id for s in deprecated_sensors]), - ) - async_add_entities(sensors) @@ -90,6 +49,7 @@ class OpenThermSensor(SensorEntity): """Representation of an OpenTherm Gateway sensor.""" _attr_should_poll = False + _attr_entity_registry_enabled_default = False def __init__(self, gw_dev, var, source, device_class, unit, friendly_name_format): """Initialize the OpenTherm Gateway sensor.""" @@ -99,37 +59,39 @@ def __init__(self, gw_dev, var, source, device_class, unit, friendly_name_format self._gateway = gw_dev self._var = var self._source = source - self._value = None - self._device_class = device_class - self._unit = unit + self._attr_device_class = device_class + self._attr_native_unit_of_measurement = unit if TRANSLATE_SOURCE[source] is not None: friendly_name_format = ( f"{friendly_name_format} ({TRANSLATE_SOURCE[source]})" ) - self._friendly_name = friendly_name_format.format(gw_dev.name) + self._attr_name = friendly_name_format.format(gw_dev.name) self._unsub_updates = None + self._attr_unique_id = f"{gw_dev.gw_id}-{source}-{var}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, gw_dev.gw_id)}, + manufacturer="Schelte Bron", + model="OpenTherm Gateway", + name=gw_dev.name, + sw_version=gw_dev.gw_version, + ) async def async_added_to_hass(self) -> None: """Subscribe to updates from the component.""" - _LOGGER.debug("Added OpenTherm Gateway sensor %s", self._friendly_name) + _LOGGER.debug("Added OpenTherm Gateway sensor %s", self._attr_name) self._unsub_updates = async_dispatcher_connect( self.hass, self._gateway.update_signal, self.receive_report ) async def async_will_remove_from_hass(self) -> None: """Unsubscribe from updates from the component.""" - _LOGGER.debug("Removing OpenTherm Gateway sensor %s", self._friendly_name) + _LOGGER.debug("Removing OpenTherm Gateway sensor %s", self._attr_name) self._unsub_updates() @property def available(self): """Return availability of the sensor.""" - return self._value is not None - - @property - def entity_registry_enabled_default(self): - """Disable sensors by default.""" - return False + return self._attr_native_value is not None @callback def receive_report(self, status): @@ -137,65 +99,5 @@ def receive_report(self, status): value = status[self._source].get(self._var) if isinstance(value, float): value = f"{value:2.1f}" - self._value = value + self._attr_native_value = value self.async_write_ha_state() - - @property - def name(self): - """Return the friendly name of the sensor.""" - return self._friendly_name - - @property - def device_info(self) -> DeviceInfo: - """Return device info.""" - return DeviceInfo( - identifiers={(DOMAIN, self._gateway.gw_id)}, - manufacturer="Schelte Bron", - model="OpenTherm Gateway", - name=self._gateway.name, - sw_version=self._gateway.gw_version, - ) - - @property - def unique_id(self): - """Return a unique ID.""" - return f"{self._gateway.gw_id}-{self._source}-{self._var}" - - @property - def device_class(self): - """Return the device class.""" - return self._device_class - - @property - def native_value(self): - """Return the state of the device.""" - return self._value - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement.""" - return self._unit - - -class DeprecatedOpenThermSensor(OpenThermSensor): - """Represent a deprecated OpenTherm Gateway Sensor.""" - - # pylint: disable=super-init-not-called - def __init__(self, gw_dev, var, device_class, unit, friendly_name_format): - """Initialize the OpenTherm Gateway sensor.""" - self.entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, f"{var}_{gw_dev.gw_id}", hass=gw_dev.hass - ) - self._gateway = gw_dev - self._var = var - self._source = DEPRECATED_SENSOR_SOURCE_LOOKUP[var] - self._value = None - self._device_class = device_class - self._unit = unit - self._friendly_name = friendly_name_format.format(gw_dev.name) - self._unsub_updates = None - - @property - def unique_id(self): - """Return a unique ID.""" - return f"{self._gateway.gw_id}-{self._var}" diff --git a/homeassistant/components/openuv/__init__.py b/homeassistant/components/openuv/__init__.py index cb8d1bffceb7d3..4df91cf4e15efc 100644 --- a/homeassistant/components/openuv/__init__.py +++ b/homeassistant/components/openuv/__init__.py @@ -18,6 +18,7 @@ ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -126,6 +127,11 @@ def __init__( f"{coordinator.latitude}_{coordinator.longitude}_{description.key}" ) self.entity_description = description + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{coordinator.latitude}_{coordinator.longitude}")}, + name="OpenUV", + entry_type=DeviceEntryType.SERVICE, + ) @callback def _handle_coordinator_update(self) -> None: diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json index 05e89ea96d4bdc..002495b951791c 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.0.33"] + "requirements": ["opower==0.0.34"] } diff --git a/homeassistant/components/opower/sensor.py b/homeassistant/components/opower/sensor.py index 6be74deaebf734..175bef01449959 100644 --- a/homeassistant/components/opower/sensor.py +++ b/homeassistant/components/opower/sensor.py @@ -45,6 +45,7 @@ class OpowerEntityDescription(SensorEntityDescription, OpowerEntityDescriptionMi name="Current bill electric usage to date", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + # Not TOTAL_INCREASING because it can decrease for accounts with solar state_class=SensorStateClass.TOTAL, suggested_display_precision=0, value_fn=lambda data: data.usage_to_date, diff --git a/homeassistant/components/overkiz/cover_entities/vertical_cover.py b/homeassistant/components/overkiz/cover_entities/vertical_cover.py index 6e72dacf5c6d62..2bc6f73103fe0e 100644 --- a/homeassistant/components/overkiz/cover_entities/vertical_cover.py +++ b/homeassistant/components/overkiz/cover_entities/vertical_cover.py @@ -45,6 +45,17 @@ class VerticalCover(OverkizGenericCover): """Representation of an Overkiz vertical cover.""" + def __init__( + self, device_url: str, coordinator: OverkizDataUpdateCoordinator + ) -> None: + """Initialize vertical cover.""" + super().__init__(device_url, coordinator) + self._attr_device_class = ( + OVERKIZ_DEVICE_TO_DEVICE_CLASS.get(self.device.widget) + or OVERKIZ_DEVICE_TO_DEVICE_CLASS.get(self.device.ui_class) + or CoverDeviceClass.BLIND + ) + @property def supported_features(self) -> CoverEntityFeature: """Flag supported features.""" @@ -64,15 +75,6 @@ def supported_features(self) -> CoverEntityFeature: return supported_features - @property - def device_class(self) -> CoverDeviceClass: - """Return the class of the device.""" - return ( - OVERKIZ_DEVICE_TO_DEVICE_CLASS.get(self.device.widget) - or OVERKIZ_DEVICE_TO_DEVICE_CLASS.get(self.device.ui_class) - or CoverDeviceClass.BLIND - ) - @property def current_cover_position(self) -> int | None: """Return current position of cover. diff --git a/homeassistant/components/overkiz/manifest.json b/homeassistant/components/overkiz/manifest.json index 8cf029adb549ad..d88996c7e024a0 100644 --- a/homeassistant/components/overkiz/manifest.json +++ b/homeassistant/components/overkiz/manifest.json @@ -13,7 +13,7 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"], - "requirements": ["pyoverkiz==1.10.1"], + "requirements": ["pyoverkiz==1.9.0"], "zeroconf": [ { "type": "_kizbox._tcp.local.", diff --git a/homeassistant/components/person/__init__.py b/homeassistant/components/person/__init__.py index ea325380e111b0..49b719a549032a 100644 --- a/homeassistant/components/person/__init__.py +++ b/homeassistant/components/person/__init__.py @@ -47,9 +47,6 @@ ) from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import async_track_state_change_event -from homeassistant.helpers.integration_platform import ( - async_process_integration_platform_for_component, -) from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType @@ -333,9 +330,6 @@ async def filter_yaml_data(hass: HomeAssistant, persons: list[dict]) -> list[dic async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the person component.""" - # Process integration platforms right away since - # we will create entities before firing EVENT_COMPONENT_LOADED - await async_process_integration_platform_for_component(hass, DOMAIN) entity_component = EntityComponent[Person](_LOGGER, DOMAIN, hass) id_manager = collection.IDManager() yaml_collection = collection.YamlCollection( @@ -397,6 +391,8 @@ async def async_reload_yaml(call: ServiceCall) -> None: class Person(collection.CollectionEntity, RestoreEntity): """Represent a tracked person.""" + _entity_component_unrecorded_attributes = frozenset({ATTR_DEVICE_TRACKERS}) + _attr_should_poll = False editable: bool diff --git a/homeassistant/components/person/recorder.py b/homeassistant/components/person/recorder.py deleted file mode 100644 index 7c0fdf5225800c..00000000000000 --- a/homeassistant/components/person/recorder.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.core import HomeAssistant, callback - -from . import ATTR_DEVICE_TRACKERS - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude large and chatty update attributes from being recorded.""" - return {ATTR_DEVICE_TRACKERS} diff --git a/homeassistant/components/picnic/sensor.py b/homeassistant/components/picnic/sensor.py index d4582afa3b2bbc..6e35c27bbfb27d 100644 --- a/homeassistant/components/picnic/sensor.py +++ b/homeassistant/components/picnic/sensor.py @@ -256,9 +256,15 @@ def __init__( self.entity_description = description self.entity_id = f"sensor.picnic_{description.key}" - self._service_unique_id = config_entry.unique_id self._attr_unique_id = f"{config_entry.unique_id}.{description.key}" + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, cast(str, config_entry.unique_id))}, + manufacturer="Picnic", + model=config_entry.unique_id, + name=f"Picnic: {coordinator.data[ADDRESS]}", + ) @property def native_value(self) -> StateType | datetime: @@ -269,14 +275,3 @@ def native_value(self) -> StateType | datetime: else {} ) return self.entity_description.value_fn(data_set) - - @property - def device_info(self) -> DeviceInfo: - """Return device info.""" - return DeviceInfo( - entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, cast(str, self._service_unique_id))}, - manufacturer="Picnic", - model=self._service_unique_id, - name=f"Picnic: {self.coordinator.data[ADDRESS]}", - ) diff --git a/homeassistant/components/plaato/binary_sensor.py b/homeassistant/components/plaato/binary_sensor.py index a8b7dc51c1e199..e8fbaa5d6f15d5 100644 --- a/homeassistant/components/plaato/binary_sensor.py +++ b/homeassistant/components/plaato/binary_sensor.py @@ -39,20 +39,17 @@ async def async_setup_entry( class PlaatoBinarySensor(PlaatoEntity, BinarySensorEntity): """Representation of a Binary Sensor.""" + def __init__(self, data, sensor_type, coordinator=None) -> None: + """Initialize plaato binary sensor.""" + super().__init__(data, sensor_type, coordinator) + if sensor_type is PlaatoKeg.Pins.LEAK_DETECTION: + self._attr_device_class = BinarySensorDeviceClass.PROBLEM + elif sensor_type is PlaatoKeg.Pins.POURING: + self._attr_device_class = BinarySensorDeviceClass.OPENING + @property def is_on(self): """Return true if the binary sensor is on.""" if self._coordinator is not None: return self._coordinator.data.binary_sensors.get(self._sensor_type) return False - - @property - def device_class(self) -> BinarySensorDeviceClass | None: - """Return the class of this device, from BinarySensorDeviceClass.""" - if self._coordinator is None: - return None - if self._sensor_type is PlaatoKeg.Pins.LEAK_DETECTION: - return BinarySensorDeviceClass.PROBLEM - if self._sensor_type is PlaatoKeg.Pins.POURING: - return BinarySensorDeviceClass.OPENING - return None diff --git a/homeassistant/components/plaato/entity.py b/homeassistant/components/plaato/entity.py index 755ff8d2ae7369..b7650567c2bb53 100644 --- a/homeassistant/components/plaato/entity.py +++ b/homeassistant/components/plaato/entity.py @@ -30,7 +30,18 @@ def __init__(self, data, sensor_type, coordinator=None): self._device_id = data[DEVICE][DEVICE_ID] self._device_type = data[DEVICE][DEVICE_TYPE] self._device_name = data[DEVICE][DEVICE_NAME] - self._state = 0 + self._attr_unique_id = f"{self._device_id}_{self._sensor_type}" + self._attr_name = f"{DOMAIN} {self._device_type} {self._device_name} {self._sensor_name}".title() + sw_version = None + if firmware := self._sensor_data.firmware_version: + sw_version = firmware + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._device_id)}, + manufacturer="Plaato", + model=self._device_type, + name=self._device_name, + sw_version=sw_version, + ) @property def _attributes(self) -> dict: @@ -46,28 +57,6 @@ def _sensor_data(self) -> PlaatoDevice: return self._coordinator.data return self._entry_data[SENSOR_DATA] - @property - def name(self): - """Return the name of the sensor.""" - return f"{DOMAIN} {self._device_type} {self._device_name} {self._sensor_name}".title() - - @property - def unique_id(self): - """Return the unique ID of this sensor.""" - return f"{self._device_id}_{self._sensor_type}" - - @property - def device_info(self) -> DeviceInfo: - """Get device info.""" - sw_version = self._sensor_data.firmware_version - return DeviceInfo( - identifiers={(DOMAIN, self._device_id)}, - manufacturer="Plaato", - model=self._device_type, - name=self._device_name, - sw_version=sw_version if sw_version != "" else None, - ) - @property def extra_state_attributes(self): """Return the state attributes of the monitored installation.""" diff --git a/homeassistant/components/plaato/sensor.py b/homeassistant/components/plaato/sensor.py index b43e18e52f62d2..f3d9a5c3e41154 100644 --- a/homeassistant/components/plaato/sensor.py +++ b/homeassistant/components/plaato/sensor.py @@ -72,17 +72,11 @@ async def _async_update_from_webhook(device_id, sensor_data: PlaatoDevice): class PlaatoSensor(PlaatoEntity, SensorEntity): """Representation of a Plaato Sensor.""" - @property - def device_class(self) -> SensorDeviceClass | None: - """Return the class of this device, from SensorDeviceClass.""" - if ( - self._coordinator is not None - and self._sensor_type == PlaatoKeg.Pins.TEMPERATURE - ): - return SensorDeviceClass.TEMPERATURE - if self._sensor_type == ATTR_TEMP: - return SensorDeviceClass.TEMPERATURE - return None + def __init__(self, data, sensor_type, coordinator=None) -> None: + """Initialize plaato sensor.""" + super().__init__(data, sensor_type, coordinator) + if sensor_type is PlaatoKeg.Pins.TEMPERATURE or sensor_type == ATTR_TEMP: + self._attr_device_class = SensorDeviceClass.TEMPERATURE @property def native_value(self): diff --git a/homeassistant/components/plex/button.py b/homeassistant/components/plex/button.py index 58e0b78560b2c0..985b4ccb4e95fa 100644 --- a/homeassistant/components/plex/button.py +++ b/homeassistant/components/plex/button.py @@ -38,17 +38,13 @@ def __init__(self, server_id: str, server_name: str) -> None: self.server_id = server_id self._attr_name = f"Scan Clients ({server_name})" self._attr_unique_id = f"plex-scan_clients-{self.server_id}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, server_id)}, + manufacturer="Plex", + ) async def async_press(self) -> None: """Press the button.""" async_dispatcher_send( self.hass, PLEX_UPDATE_PLATFORMS_SIGNAL.format(self.server_id) ) - - @property - def device_info(self) -> DeviceInfo: - """Return a device description for device registry.""" - return DeviceInfo( - identifiers={(DOMAIN, self.server_id)}, - manufacturer="Plex", - ) diff --git a/homeassistant/components/plex/media_player.py b/homeassistant/components/plex/media_player.py index 23f2895fd51b69..3e6875f98b9edb 100644 --- a/homeassistant/components/plex/media_player.py +++ b/homeassistant/components/plex/media_player.py @@ -117,6 +117,10 @@ def _async_add_entities(hass, registry, async_add_entities, server_id, new_entit class PlexMediaPlayer(MediaPlayerEntity): """Representation of a Plex device.""" + _attr_available = False + _attr_should_poll = False + _attr_state = MediaPlayerState.IDLE + def __init__(self, plex_server, device, player_source, session=None): """Initialize the Plex device.""" self.plex_server = plex_server @@ -136,9 +140,6 @@ def __init__(self, plex_server, device, player_source, session=None): self._volume_level = 1 # since we can't retrieve remotely self._volume_muted = False # since we can't retrieve remotely - self._attr_available = False - self._attr_should_poll = False - self._attr_state = MediaPlayerState.IDLE self._attr_unique_id = ( f"{self.plex_server.machine_identifier}:{self.machine_identifier}" ) diff --git a/homeassistant/components/plex/sensor.py b/homeassistant/components/plex/sensor.py index a705d11cb41e8f..972cd8d4bc9e45 100644 --- a/homeassistant/components/plex/sensor.py +++ b/homeassistant/components/plex/sensor.py @@ -129,6 +129,11 @@ def device_info(self) -> DeviceInfo | None: class PlexLibrarySectionSensor(SensorEntity): """Representation of a Plex library section sensor.""" + _attr_available = True + _attr_entity_registry_enabled_default = False + _attr_should_poll = False + _attr_native_unit_of_measurement = "Items" + def __init__(self, hass, plex_server, plex_library_section): """Initialize the sensor.""" self._server = plex_server @@ -137,14 +142,10 @@ def __init__(self, hass, plex_server, plex_library_section): self.library_section = plex_library_section self.library_type = plex_library_section.type - self._attr_available = True - self._attr_entity_registry_enabled_default = False self._attr_extra_state_attributes = {} self._attr_icon = LIBRARY_ICON_LOOKUP.get(self.library_type, "mdi:plex") self._attr_name = f"{self.server_name} Library - {plex_library_section.title}" - self._attr_should_poll = False self._attr_unique_id = f"library-{self.server_id}-{plex_library_section.uuid}" - self._attr_native_unit_of_measurement = "Items" async def async_added_to_hass(self) -> None: """Run when about to be added to hass.""" diff --git a/homeassistant/components/plugwise/manifest.json b/homeassistant/components/plugwise/manifest.json index ef0f01b38f7146..e87e1f0c281dc3 100644 --- a/homeassistant/components/plugwise/manifest.json +++ b/homeassistant/components/plugwise/manifest.json @@ -7,6 +7,6 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["crcmod", "plugwise"], - "requirements": ["plugwise==0.31.9"], + "requirements": ["plugwise==0.32.2"], "zeroconf": ["_plugwise._tcp.local."] } diff --git a/homeassistant/components/plugwise/number.py b/homeassistant/components/plugwise/number.py index 5979480d90f3a2..7e387abea02805 100644 --- a/homeassistant/components/plugwise/number.py +++ b/homeassistant/components/plugwise/number.py @@ -27,7 +27,7 @@ class PlugwiseEntityDescriptionMixin: """Mixin values for Plugwise entities.""" - command: Callable[[Smile, str, float], Awaitable[None]] + command: Callable[[Smile, str, str, float], Awaitable[None]] @dataclass @@ -43,7 +43,9 @@ class PlugwiseNumberEntityDescription( PlugwiseNumberEntityDescription( key="maximum_boiler_temperature", translation_key="maximum_boiler_temperature", - command=lambda api, number, value: api.set_number_setpoint(number, value), + command=lambda api, number, dev_id, value: api.set_number_setpoint( + number, dev_id, value + ), device_class=NumberDeviceClass.TEMPERATURE, entity_category=EntityCategory.CONFIG, native_unit_of_measurement=UnitOfTemperature.CELSIUS, @@ -51,7 +53,19 @@ class PlugwiseNumberEntityDescription( PlugwiseNumberEntityDescription( key="max_dhw_temperature", translation_key="max_dhw_temperature", - command=lambda api, number, value: api.set_number_setpoint(number, value), + command=lambda api, number, dev_id, value: api.set_number_setpoint( + number, dev_id, value + ), + device_class=NumberDeviceClass.TEMPERATURE, + entity_category=EntityCategory.CONFIG, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), + PlugwiseNumberEntityDescription( + key="temperature_offset", + translation_key="temperature_offset", + command=lambda api, number, dev_id, value: api.set_temperature_offset( + number, dev_id, value + ), device_class=NumberDeviceClass.TEMPERATURE, entity_category=EntityCategory.CONFIG, native_unit_of_measurement=UnitOfTemperature.CELSIUS, @@ -94,6 +108,7 @@ def __init__( ) -> None: """Initiate Plugwise Number.""" super().__init__(coordinator, device_id) + self.device_id = device_id self.entity_description = description self._attr_unique_id = f"{device_id}-{description.key}" self._attr_mode = NumberMode.BOX @@ -109,6 +124,6 @@ def native_value(self) -> float: async def async_set_native_value(self, value: float) -> None: """Change to the new setpoint value.""" await self.entity_description.command( - self.coordinator.api, self.entity_description.key, value + self.coordinator.api, self.entity_description.key, self.device_id, value ) await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/plugwise/strings.json b/homeassistant/components/plugwise/strings.json index 5210f8a6dc0b2e..f85c83819fac04 100644 --- a/homeassistant/components/plugwise/strings.json +++ b/homeassistant/components/plugwise/strings.json @@ -9,6 +9,9 @@ "host": "[%key:common::config_flow::data::ip%]", "port": "[%key:common::config_flow::data::port%]", "username": "Smile Username" + }, + "data_description": { + "host": "Leave empty if using Auto Discovery" } } }, @@ -79,6 +82,9 @@ }, "max_dhw_temperature": { "name": "Domestic hot water setpoint" + }, + "temperature_offset": { + "name": "Temperature offset" } }, "select": { diff --git a/homeassistant/components/plum_lightpad/light.py b/homeassistant/components/plum_lightpad/light.py index 2c1f7daa880400..9464e66e3a962c 100644 --- a/homeassistant/components/plum_lightpad/light.py +++ b/homeassistant/components/plum_lightpad/light.py @@ -73,6 +73,14 @@ def __init__(self, load): """Initialize the light.""" self._load = load self._brightness = load.level + unique_id = f"{load.llid}.light" + self._attr_unique_id = unique_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + manufacturer="Plum", + model="Dimmer", + name=load.name, + ) async def async_added_to_hass(self) -> None: """Subscribe to dimmerchange events.""" @@ -83,21 +91,6 @@ def dimmerchange(self, event): self._brightness = event["level"] self.schedule_update_ha_state() - @property - def unique_id(self): - """Combine logical load ID with .light to guarantee it is unique.""" - return f"{self._load.llid}.light" - - @property - def device_info(self) -> DeviceInfo: - """Return the device info.""" - return DeviceInfo( - identifiers={(DOMAIN, self.unique_id)}, - manufacturer="Plum", - model="Dimmer", - name=self._load.name, - ) - @property def brightness(self) -> int: """Return the brightness of this switch between 0..255.""" @@ -138,18 +131,27 @@ class GlowRing(LightEntity): _attr_color_mode = ColorMode.HS _attr_should_poll = False _attr_supported_color_modes = {ColorMode.HS} + _attr_icon = "mdi:crop-portrait" def __init__(self, lightpad): """Initialize the light.""" self._lightpad = lightpad - self._name = f"{lightpad.friendly_name} Glow Ring" + self._attr_name = f"{lightpad.friendly_name} Glow Ring" - self._state = lightpad.glow_enabled + self._attr_is_on = lightpad.glow_enabled self._glow_intensity = lightpad.glow_intensity + unique_id = f"{self._lightpad.lpid}.glow" + self._attr_unique_id = unique_id self._red = lightpad.glow_color["red"] self._green = lightpad.glow_color["green"] self._blue = lightpad.glow_color["blue"] + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + manufacturer="Plum", + model="Glow Ring", + name=self._attr_name, + ) async def async_added_to_hass(self) -> None: """Subscribe to configchange events.""" @@ -159,13 +161,12 @@ def configchange_event(self, event): """Handle Configuration change event.""" config = event["changes"] - self._state = config["glowEnabled"] + self._attr_is_on = config["glowEnabled"] self._glow_intensity = config["glowIntensity"] self._red = config["glowColor"]["red"] self._green = config["glowColor"]["green"] self._blue = config["glowColor"]["blue"] - self.schedule_update_ha_state() @property @@ -173,46 +174,11 @@ def hs_color(self): """Return the hue and saturation color value [float, float].""" return color_util.color_RGB_to_hs(self._red, self._green, self._blue) - @property - def unique_id(self): - """Combine LightPad ID with .glow to guarantee it is unique.""" - return f"{self._lightpad.lpid}.glow" - - @property - def name(self): - """Return the name of the switch if any.""" - return self._name - - @property - def device_info(self) -> DeviceInfo: - """Return the device info.""" - return DeviceInfo( - identifiers={(DOMAIN, self.unique_id)}, - manufacturer="Plum", - model="Glow Ring", - name=self.name, - ) - @property def brightness(self) -> int: """Return the brightness of this switch between 0..255.""" return min(max(int(round(self._glow_intensity * 255, 0)), 0), 255) - @property - def glow_intensity(self): - """Brightness in float form.""" - return self._glow_intensity - - @property - def is_on(self) -> bool: - """Return true if light is on.""" - return self._state - - @property - def icon(self): - """Return the crop-portrait icon representing the glow ring.""" - return "mdi:crop-portrait" - async def async_turn_on(self, **kwargs: Any) -> None: """Turn the light on.""" if ATTR_BRIGHTNESS in kwargs: diff --git a/homeassistant/components/point/__init__.py b/homeassistant/components/point/__init__.py index 2030483d9cdb56..130ea116cc1004 100644 --- a/homeassistant/components/point/__init__.py +++ b/homeassistant/components/point/__init__.py @@ -264,9 +264,20 @@ def __init__(self, point_client, device_id, device_class): self._client = point_client self._id = device_id self._name = self.device.name - self._device_class = device_class + self._attr_device_class = device_class self._updated = utc_from_timestamp(0) - self._value = None + self._attr_unique_id = f"point.{device_id}-{device_class}" + device = self.device.device + self._attr_device_info = DeviceInfo( + connections={(dr.CONNECTION_NETWORK_MAC, device["device_mac"])}, + identifiers={(DOMAIN, device["device_id"])}, + manufacturer="Minut", + model=f"Point v{device['hardware_version']}", + name=device["description"], + sw_version=device["firmware"]["installed"], + via_device=(DOMAIN, device["home"]), + ) + self._attr_name = f"{self._name} {device_class.capitalize()}" def __str__(self): """Return string representation of device.""" @@ -298,11 +309,6 @@ def device(self): """Return the representation of the device.""" return self._client.device(self.device_id) - @property - def device_class(self): - """Return the device class.""" - return self._device_class - @property def device_id(self): """Return the id of the device.""" @@ -317,25 +323,6 @@ def extra_state_attributes(self): ) return attrs - @property - def device_info(self) -> DeviceInfo: - """Return a device description for device registry.""" - device = self.device.device - return DeviceInfo( - connections={(dr.CONNECTION_NETWORK_MAC, device["device_mac"])}, - identifiers={(DOMAIN, device["device_id"])}, - manufacturer="Minut", - model=f"Point v{device['hardware_version']}", - name=device["description"], - sw_version=device["firmware"]["installed"], - via_device=(DOMAIN, device["home"]), - ) - - @property - def name(self): - """Return the display name of this device.""" - return f"{self._name} {self.device_class.capitalize()}" - @property def is_updated(self): """Return true if sensor have been updated.""" @@ -344,15 +331,4 @@ def is_updated(self): @property def last_update(self): """Return the last_update time for the device.""" - last_update = parse_datetime(self.device.last_update) - return last_update - - @property - def unique_id(self): - """Return the unique id of the sensor.""" - return f"point.{self._id}-{self.device_class}" - - @property - def value(self): - """Return the sensor value.""" - return self._value + return parse_datetime(self.device.last_update) diff --git a/homeassistant/components/point/binary_sensor.py b/homeassistant/components/point/binary_sensor.py index e8db51fd0fc721..81101d2da797e6 100644 --- a/homeassistant/components/point/binary_sensor.py +++ b/homeassistant/components/point/binary_sensor.py @@ -76,6 +76,9 @@ def __init__(self, point_client, device_id, device_name): self._device_name = device_name self._async_unsub_hook_dispatcher_connect = None self._events = EVENTS[device_name] + self._attr_unique_id = f"point.{device_id}-{device_name}" + self._attr_icon = DEVICES[self._device_name].get("icon") + self._attr_name = f"{self._name} {device_name.capitalize()}" async def async_added_to_hass(self) -> None: """Call when entity is added to HOme Assistant.""" @@ -124,18 +127,3 @@ def _webhook_event(self, data, webhook): else: self._attr_is_on = _is_on self.async_write_ha_state() - - @property - def name(self): - """Return the display name of this device.""" - return f"{self._name} {self._device_name.capitalize()}" - - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return DEVICES[self._device_name].get("icon") - - @property - def unique_id(self): - """Return the unique id of the sensor.""" - return f"point.{self._id}-{self._device_name}" diff --git a/homeassistant/components/point/sensor.py b/homeassistant/components/point/sensor.py index 34571c801a62e4..462d8270f0a47c 100644 --- a/homeassistant/components/point/sensor.py +++ b/homeassistant/components/point/sensor.py @@ -98,13 +98,10 @@ async def _update_callback(self): """Update the value of the sensor.""" _LOGGER.debug("Update sensor value for %s", self) if self.is_updated: - self._value = await self.device.sensor(self.device_class) + self._attr_native_value = await self.device.sensor(self.device_class) + if self.native_value is not None: + self._attr_native_value = round( + self.native_value, self.entity_description.precision + ) self._updated = parse_datetime(self.device.last_update) self.async_write_ha_state() - - @property - def native_value(self): - """Return the state of the sensor.""" - if self.value is None: - return None - return round(self.value, self.entity_description.precision) diff --git a/homeassistant/components/private_ble_device/__init__.py b/homeassistant/components/private_ble_device/__init__.py new file mode 100644 index 00000000000000..dcb6555bbc9fd2 --- /dev/null +++ b/homeassistant/components/private_ble_device/__init__.py @@ -0,0 +1,19 @@ +"""Private BLE Device integration.""" +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +PLATFORMS = [Platform.DEVICE_TRACKER, Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up tracking of a private bluetooth device from a config entry.""" + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload entities for a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/private_ble_device/config_flow.py b/homeassistant/components/private_ble_device/config_flow.py new file mode 100644 index 00000000000000..5bf130a0396fff --- /dev/null +++ b/homeassistant/components/private_ble_device/config_flow.py @@ -0,0 +1,60 @@ +"""Config flow for the BLE Tracker.""" +from __future__ import annotations + +import base64 +import binascii +import logging + +import voluptuous as vol + +from homeassistant.components import bluetooth +from homeassistant.config_entries import ConfigFlow +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN +from .coordinator import async_last_service_info + +_LOGGER = logging.getLogger(__name__) + +CONF_IRK = "irk" + + +class BLEDeviceTrackerConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for BLE Device Tracker.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: + """Set up by user.""" + errors: dict[str, str] = {} + + if not bluetooth.async_scanner_count(self.hass, connectable=False): + return self.async_abort(reason="bluetooth_not_available") + + if user_input is not None: + irk = user_input[CONF_IRK] + if irk.startswith("irk:"): + irk = irk[4:] + + if irk.endswith("="): + irk_bytes = bytes(reversed(base64.b64decode(irk))) + else: + irk_bytes = binascii.unhexlify(irk) + + if len(irk_bytes) != 16: + errors[CONF_IRK] = "irk_not_valid" + elif not (service_info := async_last_service_info(self.hass, irk_bytes)): + errors[CONF_IRK] = "irk_not_found" + else: + await self.async_set_unique_id(irk_bytes.hex()) + return self.async_create_entry( + title=service_info.name or "BLE Device Tracker", + data={CONF_IRK: irk_bytes.hex()}, + ) + + data_schema = vol.Schema({CONF_IRK: str}) + return self.async_show_form( + step_id="user", data_schema=data_schema, errors=errors + ) diff --git a/homeassistant/components/private_ble_device/const.py b/homeassistant/components/private_ble_device/const.py new file mode 100644 index 00000000000000..086fd06bfd5780 --- /dev/null +++ b/homeassistant/components/private_ble_device/const.py @@ -0,0 +1,2 @@ +"""Constants for Private BLE Device.""" +DOMAIN = "private_ble_device" diff --git a/homeassistant/components/private_ble_device/coordinator.py b/homeassistant/components/private_ble_device/coordinator.py new file mode 100644 index 00000000000000..863b283385175b --- /dev/null +++ b/homeassistant/components/private_ble_device/coordinator.py @@ -0,0 +1,236 @@ +"""Central manager for tracking devices with random but resolvable MAC addresses.""" +from __future__ import annotations + +from collections.abc import Callable +import logging +from typing import cast + +from bluetooth_data_tools import get_cipher_for_irk, resolve_private_address +from cryptography.hazmat.primitives.ciphers import Cipher + +from homeassistant.components import bluetooth +from homeassistant.components.bluetooth.match import BluetoothCallbackMatcher +from homeassistant.core import HomeAssistant + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +UnavailableCallback = Callable[[bluetooth.BluetoothServiceInfoBleak], None] +Cancellable = Callable[[], None] + + +def async_last_service_info( + hass: HomeAssistant, irk: bytes +) -> bluetooth.BluetoothServiceInfoBleak | None: + """Find a BluetoothServiceInfoBleak for the irk. + + This iterates over all currently visible mac addresses and checks them against `irk`. + It returns the newest. + """ + + # This can't use existing data collected by the coordinator - its called when + # the coordinator doesn't know about the IRK, so doesn't optimise this lookup. + + cur: bluetooth.BluetoothServiceInfoBleak | None = None + cipher = get_cipher_for_irk(irk) + + for service_info in bluetooth.async_discovered_service_info(hass, False): + if resolve_private_address(cipher, service_info.address): + if not cur or cur.time < service_info.time: + cur = service_info + + return cur + + +class PrivateDevicesCoordinator: + """Monitor private bluetooth devices and correlate them with known IRK. + + This class should not be instanced directly - use `async_get_coordinator` to get an instance. + + There is a single shared coordinator for all instances of this integration. This is to avoid + unnecessary hashing (AES) operations as much as possible. + """ + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the manager.""" + self.hass = hass + + self._irks: dict[bytes, Cipher] = {} + self._unavailable_callbacks: dict[bytes, list[UnavailableCallback]] = {} + self._service_info_callbacks: dict[ + bytes, list[bluetooth.BluetoothCallback] + ] = {} + + self._mac_to_irk: dict[str, bytes] = {} + self._irk_to_mac: dict[bytes, str] = {} + + # These MAC addresses have been compared to the IRK list + # They are unknown, so we can ignore them. + self._ignored: dict[str, Cancellable] = {} + + self._unavailability_trackers: dict[bytes, Cancellable] = {} + self._listener_cancel: Cancellable | None = None + + def _async_ensure_started(self) -> None: + if not self._listener_cancel: + self._listener_cancel = bluetooth.async_register_callback( + self.hass, + self._async_track_service_info, + BluetoothCallbackMatcher(connectable=False), + bluetooth.BluetoothScanningMode.ACTIVE, + ) + + def _async_ensure_stopped(self) -> None: + if self._listener_cancel: + self._listener_cancel() + self._listener_cancel = None + + for cancel in self._ignored.values(): + cancel() + self._ignored.clear() + + def _async_track_unavailable( + self, service_info: bluetooth.BluetoothServiceInfoBleak + ) -> None: + # This should be called when the current MAC address associated with an IRK goes away. + if resolved := self._mac_to_irk.get(service_info.address): + if callbacks := self._unavailable_callbacks.get(resolved): + for cb in callbacks: + cb(service_info) + return + + def _async_irk_resolved_to_mac(self, irk: bytes, mac: str) -> None: + if previous_mac := self._irk_to_mac.get(irk): + self._mac_to_irk.pop(previous_mac, None) + + self._mac_to_irk[mac] = irk + self._irk_to_mac[irk] = mac + + # Stop ignoring this MAC + self._ignored.pop(mac, None) + + # Ignore availability events for the previous address + if cancel := self._unavailability_trackers.pop(irk, None): + cancel() + + # Track available for new address + self._unavailability_trackers[irk] = bluetooth.async_track_unavailable( + self.hass, self._async_track_unavailable, mac, False + ) + + def _async_track_service_info( + self, + service_info: bluetooth.BluetoothServiceInfoBleak, + change: bluetooth.BluetoothChange, + ) -> None: + mac = service_info.address + + if mac in self._ignored: + return + + if resolved := self._mac_to_irk.get(mac): + if callbacks := self._service_info_callbacks.get(resolved): + for cb in callbacks: + cb(service_info, change) + return + + for irk, cipher in self._irks.items(): + if resolve_private_address(cipher, service_info.address): + self._async_irk_resolved_to_mac(irk, mac) + if callbacks := self._service_info_callbacks.get(irk): + for cb in callbacks: + cb(service_info, change) + return + + def _unignore(service_info: bluetooth.BluetoothServiceInfoBleak) -> None: + self._ignored.pop(service_info.address, None) + + self._ignored[mac] = bluetooth.async_track_unavailable( + self.hass, _unignore, mac, False + ) + + def _async_maybe_learn_irk(self, irk: bytes) -> None: + """Add irk to list of irks that we can use to resolve RPAs.""" + if irk not in self._irks: + if service_info := async_last_service_info(self.hass, irk): + self._async_irk_resolved_to_mac(irk, service_info.address) + self._irks[irk] = get_cipher_for_irk(irk) + + def _async_maybe_forget_irk(self, irk: bytes) -> None: + """If no downstream caller is tracking this irk, lets forget it.""" + if irk in self._service_info_callbacks or irk in self._unavailable_callbacks: + return + + # Ignore availability events for this irk as no + # one is listening. + if cancel := self._unavailability_trackers.pop(irk, None): + cancel() + + del self._irks[irk] + + if mac := self._irk_to_mac.pop(irk, None): + self._mac_to_irk.pop(mac, None) + + if not self._mac_to_irk: + self._async_ensure_stopped() + + def async_track_service_info( + self, callback: bluetooth.BluetoothCallback, irk: bytes + ) -> Cancellable: + """Receive a callback when a new advertisement is received for an irk. + + Returns a callback that can be used to cancel the registration. + """ + self._async_ensure_started() + self._async_maybe_learn_irk(irk) + + callbacks = self._service_info_callbacks.setdefault(irk, []) + callbacks.append(callback) + + def _unsubscribe() -> None: + callbacks.remove(callback) + if not callbacks: + self._service_info_callbacks.pop(irk, None) + self._async_maybe_forget_irk(irk) + + return _unsubscribe + + def async_track_unavailable( + self, + callback: UnavailableCallback, + irk: bytes, + ) -> Cancellable: + """Register to receive a callback when an irk is unavailable. + + Returns a callback that can be used to cancel the registration. + """ + self._async_ensure_started() + self._async_maybe_learn_irk(irk) + + callbacks = self._unavailable_callbacks.setdefault(irk, []) + callbacks.append(callback) + + def _unsubscribe() -> None: + callbacks.remove(callback) + if not callbacks: + self._unavailable_callbacks.pop(irk, None) + + self._async_maybe_forget_irk(irk) + + return _unsubscribe + + +def async_get_coordinator(hass: HomeAssistant) -> PrivateDevicesCoordinator: + """Create or return an existing PrivateDeviceManager. + + There should only be one per HomeAssistant instance. Associating private + mac addresses with an IRK involves AES operations. We don't want to + duplicate that work. + """ + if existing := hass.data.get(DOMAIN): + return cast(PrivateDevicesCoordinator, existing) + + pdm = hass.data[DOMAIN] = PrivateDevicesCoordinator(hass) + + return pdm diff --git a/homeassistant/components/private_ble_device/device_tracker.py b/homeassistant/components/private_ble_device/device_tracker.py new file mode 100644 index 00000000000000..64e23b25ebec78 --- /dev/null +++ b/homeassistant/components/private_ble_device/device_tracker.py @@ -0,0 +1,75 @@ +"""Tracking for bluetooth low energy devices.""" +from __future__ import annotations + +from collections.abc import Mapping +import logging + +from homeassistant.components import bluetooth +from homeassistant.components.device_tracker import SourceType +from homeassistant.components.device_tracker.config_entry import BaseTrackerEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_HOME, STATE_NOT_HOME +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .entity import BasePrivateDeviceEntity + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Load Device Tracker entities for a config entry.""" + async_add_entities([BasePrivateDeviceTracker(config_entry)]) + + +class BasePrivateDeviceTracker(BasePrivateDeviceEntity, BaseTrackerEntity): + """A trackable Private Bluetooth Device.""" + + _attr_should_poll = False + _attr_has_entity_name = True + _attr_name = None + + @property + def extra_state_attributes(self) -> Mapping[str, str]: + """Return extra state attributes for this device.""" + if last_info := self._last_info: + return { + "current_address": last_info.address, + "source": last_info.source, + } + return {} + + @callback + def _async_track_unavailable( + self, service_info: bluetooth.BluetoothServiceInfoBleak + ) -> None: + self._last_info = None + self.async_write_ha_state() + + @callback + def _async_track_service_info( + self, + service_info: bluetooth.BluetoothServiceInfoBleak, + change: bluetooth.BluetoothChange, + ) -> None: + self._last_info = service_info + self.async_write_ha_state() + + @property + def state(self) -> str: + """Return the state of the device.""" + return STATE_HOME if self._last_info else STATE_NOT_HOME + + @property + def source_type(self) -> SourceType: + """Return the source type, eg gps or router, of the device.""" + return SourceType.BLUETOOTH_LE + + @property + def icon(self) -> str: + """Return device icon.""" + return "mdi:bluetooth-connect" if self._last_info else "mdi:bluetooth-off" diff --git a/homeassistant/components/private_ble_device/entity.py b/homeassistant/components/private_ble_device/entity.py new file mode 100644 index 00000000000000..978313e9671f52 --- /dev/null +++ b/homeassistant/components/private_ble_device/entity.py @@ -0,0 +1,74 @@ +"""Tracking for bluetooth low energy devices.""" +from __future__ import annotations + +from abc import abstractmethod +import binascii + +from homeassistant.components import bluetooth +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN +from .coordinator import async_get_coordinator, async_last_service_info + + +class BasePrivateDeviceEntity(Entity): + """Base Private Bluetooth Entity.""" + + _attr_should_poll = False + _attr_has_entity_name = True + + def __init__(self, config_entry: ConfigEntry) -> None: + """Set up a new BleScanner entity.""" + irk = config_entry.data["irk"] + + if self.translation_key: + self._attr_unique_id = f"{irk}_{self.translation_key}" + else: + self._attr_unique_id = irk + + self._attr_device_info = DeviceInfo( + name=f"Private BLE Device {irk[:6]}", + identifiers={(DOMAIN, irk)}, + ) + + self._entry = config_entry + self._irk = binascii.unhexlify(irk) + self._last_info: bluetooth.BluetoothServiceInfoBleak | None = None + + async def async_added_to_hass(self) -> None: + """Configure entity when it is added to Home Assistant.""" + coordinator = async_get_coordinator(self.hass) + self.async_on_remove( + coordinator.async_track_service_info( + self._async_track_service_info, self._irk + ) + ) + self.async_on_remove( + coordinator.async_track_unavailable( + self._async_track_unavailable, self._irk + ) + ) + + if service_info := async_last_service_info(self.hass, self._irk): + self._async_track_service_info( + service_info, bluetooth.BluetoothChange.ADVERTISEMENT + ) + + @abstractmethod + @callback + def _async_track_unavailable( + self, service_info: bluetooth.BluetoothServiceInfoBleak + ) -> None: + """Respond when the bluetooth device being tracked is no longer visible.""" + + @abstractmethod + @callback + def _async_track_service_info( + self, + service_info: bluetooth.BluetoothServiceInfoBleak, + change: bluetooth.BluetoothChange, + ) -> None: + """Respond when the bluetooth device being tracked broadcasted updated information.""" diff --git a/homeassistant/components/private_ble_device/manifest.json b/homeassistant/components/private_ble_device/manifest.json new file mode 100644 index 00000000000000..3497138178cfba --- /dev/null +++ b/homeassistant/components/private_ble_device/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "private_ble_device", + "name": "Private BLE Device", + "codeowners": ["@Jc2k"], + "config_flow": true, + "dependencies": ["bluetooth_adapters"], + "documentation": "https://www.home-assistant.io/integrations/private_ble_device", + "iot_class": "local_push", + "requirements": ["bluetooth-data-tools==1.11.0"] +} diff --git a/homeassistant/components/private_ble_device/sensor.py b/homeassistant/components/private_ble_device/sensor.py new file mode 100644 index 00000000000000..e2f5efb669953d --- /dev/null +++ b/homeassistant/components/private_ble_device/sensor.py @@ -0,0 +1,127 @@ +"""Support for iBeacon device sensors.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from bluetooth_data_tools import calculate_distance_meters + +from homeassistant.components import bluetooth +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + EntityCategory, + UnitOfLength, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .entity import BasePrivateDeviceEntity + + +@dataclass +class PrivateDeviceSensorEntityDescriptionRequired: + """Required domain specific fields for sensor entity.""" + + value_fn: Callable[[bluetooth.BluetoothServiceInfoBleak], str | int | float | None] + + +@dataclass +class PrivateDeviceSensorEntityDescription( + SensorEntityDescription, PrivateDeviceSensorEntityDescriptionRequired +): + """Describes sensor entity.""" + + +SENSOR_DESCRIPTIONS = ( + PrivateDeviceSensorEntityDescription( + key="rssi", + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda service_info: service_info.advertisement.rssi, + state_class=SensorStateClass.MEASUREMENT, + ), + PrivateDeviceSensorEntityDescription( + key="power", + translation_key="power", + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda service_info: service_info.advertisement.tx_power, + state_class=SensorStateClass.MEASUREMENT, + ), + PrivateDeviceSensorEntityDescription( + key="estimated_distance", + translation_key="estimated_distance", + icon="mdi:signal-distance-variant", + native_unit_of_measurement=UnitOfLength.METERS, + value_fn=lambda service_info: service_info.advertisement + and service_info.advertisement.tx_power + and calculate_distance_meters( + service_info.advertisement.tx_power * 10, service_info.advertisement.rssi + ), + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.DISTANCE, + suggested_display_precision=1, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up sensors for Private BLE component.""" + async_add_entities( + PrivateBLEDeviceSensor(entry, description) + for description in SENSOR_DESCRIPTIONS + ) + + +class PrivateBLEDeviceSensor(BasePrivateDeviceEntity, SensorEntity): + """A sensor entity.""" + + entity_description: PrivateDeviceSensorEntityDescription + + def __init__( + self, + config_entry: ConfigEntry, + entity_description: PrivateDeviceSensorEntityDescription, + ) -> None: + """Initialize an sensor entity.""" + self.entity_description = entity_description + self._attr_available = False + super().__init__(config_entry) + + @callback + def _async_track_service_info( + self, + service_info: bluetooth.BluetoothServiceInfoBleak, + change: bluetooth.BluetoothChange, + ) -> None: + """Update state.""" + self._attr_available = True + self._last_info = service_info + self.async_write_ha_state() + + @callback + def _async_track_unavailable( + self, service_info: bluetooth.BluetoothServiceInfoBleak + ) -> None: + """Update state.""" + self._attr_available = False + self.async_write_ha_state() + + @property + def native_value(self) -> str | int | float | None: + """Return the state of the sensor.""" + assert self._last_info + return self.entity_description.value_fn(self._last_info) diff --git a/homeassistant/components/private_ble_device/strings.json b/homeassistant/components/private_ble_device/strings.json new file mode 100644 index 00000000000000..279ff38bc9ba10 --- /dev/null +++ b/homeassistant/components/private_ble_device/strings.json @@ -0,0 +1,30 @@ +{ + "config": { + "flow_title": "[%key:component::bluetooth::config::flow_title%]", + "step": { + "user": { + "description": "What is the IRK (Identity Resolving Key) of the BLE device you want to track?", + "data": { + "irk": "IRK" + } + } + }, + "error": { + "irk_not_found": "The provided IRK does not match any BLE devices that Home Assistant can see.", + "irk_not_valid": "The key does not look like a valid IRK." + }, + "abort": { + "bluetooth_not_available": "At least one Bluetooth adapter or remote bluetooth proxy must be configured to track Private BLE Devices." + } + }, + "entity": { + "sensor": { + "power": { + "name": "Power" + }, + "estimated_distance": { + "name": "Estimated distance" + } + } + } +} diff --git a/homeassistant/components/progettihwsw/binary_sensor.py b/homeassistant/components/progettihwsw/binary_sensor.py index e2d1025cc64a0c..ea7a7dce5c3db8 100644 --- a/homeassistant/components/progettihwsw/binary_sensor.py +++ b/homeassistant/components/progettihwsw/binary_sensor.py @@ -62,14 +62,9 @@ class ProgettihwswBinarySensor(CoordinatorEntity, BinarySensorEntity): def __init__(self, coordinator, name, sensor: Input) -> None: """Set initializing values.""" super().__init__(coordinator) - self._name = name + self._attr_name = name self._sensor = sensor - @property - def name(self): - """Return the sensor name.""" - return self._name - @property def is_on(self): """Get sensor state.""" diff --git a/homeassistant/components/progettihwsw/manifest.json b/homeassistant/components/progettihwsw/manifest.json index 6cad66e136040f..d5c91fcea10709 100644 --- a/homeassistant/components/progettihwsw/manifest.json +++ b/homeassistant/components/progettihwsw/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/progettihwsw", "iot_class": "local_polling", "loggers": ["ProgettiHWSW"], - "requirements": ["ProgettiHWSW==0.1.1"] + "requirements": ["ProgettiHWSW==0.1.3"] } diff --git a/homeassistant/components/progettihwsw/switch.py b/homeassistant/components/progettihwsw/switch.py index 77cfb6ba4d14bd..f466e11a1cca64 100644 --- a/homeassistant/components/progettihwsw/switch.py +++ b/homeassistant/components/progettihwsw/switch.py @@ -64,7 +64,7 @@ def __init__(self, coordinator, name, switch: Relay) -> None: """Initialize the values.""" super().__init__(coordinator) self._switch = switch - self._name = name + self._attr_name = name async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" @@ -81,11 +81,6 @@ async def async_toggle(self, **kwargs: Any) -> None: await self._switch.toggle() await self.coordinator.async_request_refresh() - @property - def name(self): - """Return the switch name.""" - return self._name - @property def is_on(self): """Get switch state.""" diff --git a/homeassistant/components/prometheus/__init__.py b/homeassistant/components/prometheus/__init__.py index e5d7f6cb060c3a..c96ed2e4ed3591 100644 --- a/homeassistant/components/prometheus/__init__.py +++ b/homeassistant/components/prometheus/__init__.py @@ -19,6 +19,7 @@ from homeassistant.components.cover import ATTR_POSITION, ATTR_TILT_POSITION from homeassistant.components.http import HomeAssistantView from homeassistant.components.humidifier import ATTR_AVAILABLE_MODES, ATTR_HUMIDITY +from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import ( ATTR_BATTERY_LEVEL, ATTR_DEVICE_CLASS, @@ -44,6 +45,7 @@ from homeassistant.helpers.entity_registry import EVENT_ENTITY_REGISTRY_UPDATED from homeassistant.helpers.entity_values import EntityValues from homeassistant.helpers.typing import ConfigType +from homeassistant.util.dt import as_timestamp from homeassistant.util.unit_conversion import TemperatureConverter _LOGGER = logging.getLogger(__name__) @@ -118,10 +120,15 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: default_metric, ) - hass.bus.listen(EVENT_STATE_CHANGED, metrics.handle_state_changed) + hass.bus.listen(EVENT_STATE_CHANGED, metrics.handle_state_changed_event) hass.bus.listen( EVENT_ENTITY_REGISTRY_UPDATED, metrics.handle_entity_registry_updated ) + + for state in hass.states.all(): + if entity_filter(state.entity_id): + metrics.handle_state(state) + return True @@ -147,6 +154,7 @@ def __init__( self._sensor_metric_handlers = [ self._sensor_override_component_metric, self._sensor_override_metric, + self._sensor_timestamp_metric, self._sensor_attribute_metric, self._sensor_default_metric, self._sensor_fallback_metric, @@ -159,16 +167,13 @@ def __init__( self._metrics = {} self._climate_units = climate_units - def handle_state_changed(self, event): - """Listen for new messages on the bus, and add them to Prometheus.""" + def handle_state_changed_event(self, event): + """Handle new messages from the bus.""" if (state := event.data.get("new_state")) is None: return - entity_id = state.entity_id - _LOGGER.debug("Handling state update for %s", entity_id) - domain, _ = hacore.split_entity_id(entity_id) - if not self._filter(state.entity_id): + _LOGGER.debug("Filtered out entity %s", state.entity_id) return if (old_state := event.data.get("old_state")) is not None and ( @@ -176,6 +181,14 @@ def handle_state_changed(self, event): ) != state.attributes.get(ATTR_FRIENDLY_NAME): self._remove_labelsets(old_state.entity_id, old_friendly_name) + self.handle_state(state) + + def handle_state(self, state): + """Add/update a state in Prometheus.""" + entity_id = state.entity_id + _LOGGER.debug("Handling state update for %s", entity_id) + domain, _ = hacore.split_entity_id(entity_id) + ignored_states = (STATE_UNAVAILABLE, STATE_UNKNOWN) handler = f"_handle_{domain}" @@ -292,7 +305,10 @@ def _sanitize_metric_name(metric: str) -> str: def state_as_number(state): """Return a state casted to a float.""" try: - value = state_helper.state_as_number(state) + if state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TIMESTAMP: + value = as_timestamp(state.state) + else: + value = state_helper.state_as_number(state) except ValueError: _LOGGER.debug("Could not convert %s to float", state) value = 0 @@ -576,6 +592,14 @@ def _sensor_attribute_metric(state, unit): return f"sensor_{metric}_{unit}" return None + @staticmethod + def _sensor_timestamp_metric(state, unit): + """Get metric for timestamp sensors, which have no unit of measurement attribute.""" + metric = state.attributes.get(ATTR_DEVICE_CLASS) + if metric == SensorDeviceClass.TIMESTAMP: + return f"sensor_{metric}_seconds" + return None + def _sensor_override_metric(self, state, unit): """Get metric from override in configuration.""" if self._override_metric: @@ -647,6 +671,15 @@ def _handle_counter(self, state): metric.labels(**self._labels(state)).set(self.state_as_number(state)) + def _handle_update(self, state): + metric = self._metric( + "update_state", + self.prometheus_cli.Gauge, + "Update state, indicating if an update is available (0/1)", + ) + value = self.state_as_number(state) + metric.labels(**self._labels(state)).set(value) + class PrometheusView(HomeAssistantView): """Handle Prometheus requests.""" diff --git a/homeassistant/components/ps4/strings.json b/homeassistant/components/ps4/strings.json index 644b2d61216100..163f2cc9b94c0f 100644 --- a/homeassistant/components/ps4/strings.json +++ b/homeassistant/components/ps4/strings.json @@ -7,7 +7,7 @@ "mode": { "data": { "mode": "Config Mode", - "ip_address": "IP address (Leave empty if using Auto Discovery)." + "ip_address": "[%key:common::config_flow::data::ip%]" }, "data_description": { "ip_address": "Leave blank if selecting auto-discovery." diff --git a/homeassistant/components/pushover/manifest.json b/homeassistant/components/pushover/manifest.json index 3b538f756e0591..d086321c0885ce 100644 --- a/homeassistant/components/pushover/manifest.json +++ b/homeassistant/components/pushover/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/pushover", "iot_class": "cloud_push", "loggers": ["pushover_complete"], - "requirements": ["pushover-complete==1.1.1"] + "requirements": ["pushover_complete==1.1.1"] } diff --git a/homeassistant/components/python_script/manifest.json b/homeassistant/components/python_script/manifest.json index ea153be11cf1f9..80ed6164e74b06 100644 --- a/homeassistant/components/python_script/manifest.json +++ b/homeassistant/components/python_script/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/python_script", "loggers": ["RestrictedPython"], "quality_scale": "internal", - "requirements": ["RestrictedPython==6.1"] + "requirements": ["RestrictedPython==6.2"] } diff --git a/homeassistant/components/qnap/strings.json b/homeassistant/components/qnap/strings.json index 64b3f22293aee4..a5fa3c8a8971ff 100644 --- a/homeassistant/components/qnap/strings.json +++ b/homeassistant/components/qnap/strings.json @@ -5,19 +5,19 @@ "title": "Connect to the QNAP device", "description": "This qnap sensor allows getting various statistics from your QNAP NAS.", "data": { - "host": "Hostname", + "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", "port": "[%key:common::config_flow::data::port%]", - "ssl": "Enable SSL", - "verify_ssl": "Verify SSL" + "ssl": "[%key:common::config_flow::data::ssl%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" } } }, "error": { - "cannot_connect": "Cannot connect to host", - "invalid_auth": "Bad authentication", - "unknown": "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%]" } } } diff --git a/homeassistant/components/rachio/binary_sensor.py b/homeassistant/components/rachio/binary_sensor.py index 029b1bac6e3500..652806a2bada45 100644 --- a/homeassistant/components/rachio/binary_sensor.py +++ b/homeassistant/components/rachio/binary_sensor.py @@ -59,16 +59,6 @@ class RachioControllerBinarySensor(RachioDevice, BinarySensorEntity): _attr_has_entity_name = True - def __init__(self, controller): - """Set up a new Rachio controller binary sensor.""" - super().__init__(controller) - self._state = None - - @property - def is_on(self) -> bool: - """Return whether the sensor has a 'true' value.""" - return self._state - @callback def _async_handle_any_update(self, *args, **kwargs) -> None: """Determine whether an update event applies to this device.""" @@ -98,15 +88,15 @@ def unique_id(self) -> str: 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._state = True + self._attr_is_on = True elif args[0][0][KEY_SUBTYPE] == SUBTYPE_OFFLINE: - self._state = False + self._attr_is_on = False self.async_write_ha_state() async def async_added_to_hass(self) -> None: """Subscribe to updates.""" - self._state = self._controller.init_data[KEY_STATUS] == STATUS_ONLINE + self._attr_is_on = self._controller.init_data[KEY_STATUS] == STATUS_ONLINE self.async_on_remove( async_dispatcher_connect( @@ -132,15 +122,15 @@ def unique_id(self) -> str: 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._state = True + self._attr_is_on = True elif args[0][0][KEY_SUBTYPE] == SUBTYPE_RAIN_SENSOR_DETECTION_OFF: - self._state = False + self._attr_is_on = False self.async_write_ha_state() async def async_added_to_hass(self) -> None: """Subscribe to updates.""" - self._state = self._controller.init_data[KEY_RAIN_SENSOR_TRIPPED] + self._attr_is_on = self._controller.init_data[KEY_RAIN_SENSOR_TRIPPED] self.async_on_remove( async_dispatcher_connect( diff --git a/homeassistant/components/rachio/switch.py b/homeassistant/components/rachio/switch.py index 0557a2bdb19b72..bbb08f6d46fe94 100644 --- a/homeassistant/components/rachio/switch.py +++ b/homeassistant/components/rachio/switch.py @@ -178,16 +178,6 @@ def _create_entities(hass: HomeAssistant, config_entry: ConfigEntry) -> list[Ent class RachioSwitch(RachioDevice, SwitchEntity): """Represent a Rachio state that can be toggled.""" - def __init__(self, controller): - """Initialize a new Rachio switch.""" - super().__init__(controller) - self._state = None - - @property - def is_on(self) -> bool: - """Return whether the switch is currently on.""" - return self._state - @callback def _async_handle_any_update(self, *args, **kwargs) -> None: """Determine whether an update event applies to this device.""" @@ -219,9 +209,9 @@ def unique_id(self) -> str: def _async_handle_update(self, *args, **kwargs) -> None: """Update the state using webhook data.""" if args[0][0][KEY_SUBTYPE] == SUBTYPE_SLEEP_MODE_ON: - self._state = True + self._attr_is_on = True elif args[0][0][KEY_SUBTYPE] == SUBTYPE_SLEEP_MODE_OFF: - self._state = False + self._attr_is_on = False self.async_write_ha_state() @@ -236,7 +226,7 @@ def turn_off(self, **kwargs: Any) -> None: async def async_added_to_hass(self) -> None: """Subscribe to updates.""" if KEY_ON in self._controller.init_data: - self._state = not self._controller.init_data[KEY_ON] + self._attr_is_on = not self._controller.init_data[KEY_ON] self.async_on_remove( async_dispatcher_connect( @@ -274,20 +264,20 @@ def _async_handle_update(self, *args, **kwargs) -> None: if args[0][0][KEY_SUBTYPE] == SUBTYPE_RAIN_DELAY_ON: endtime = parse_datetime(args[0][0][KEY_RAIN_DELAY_END]) _LOGGER.debug("Rain delay expires at %s", endtime) - self._state = True + self._attr_is_on = True assert endtime is not None self._cancel_update = async_track_point_in_utc_time( self.hass, self._delay_expiration, endtime ) elif args[0][0][KEY_SUBTYPE] == SUBTYPE_RAIN_DELAY_OFF: - self._state = False + self._attr_is_on = False self.async_write_ha_state() @callback def _delay_expiration(self, *args) -> None: """Trigger when a rain delay expires.""" - self._state = False + self._attr_is_on = False self._cancel_update = None self.async_write_ha_state() @@ -304,12 +294,12 @@ def turn_off(self, **kwargs: Any) -> None: async def async_added_to_hass(self) -> None: """Subscribe to updates.""" if KEY_RAIN_DELAY in self._controller.init_data: - self._state = self._controller.init_data[ + self._attr_is_on = self._controller.init_data[ KEY_RAIN_DELAY ] / 1000 > as_timestamp(now()) # If the controller was in a rain delay state during a reboot, this re-sets the timer - if self._state is True: + if self._attr_is_on is True: delay_end = utc_from_timestamp( self._controller.init_data[KEY_RAIN_DELAY] / 1000 ) @@ -330,19 +320,22 @@ async def async_added_to_hass(self) -> None: class RachioZone(RachioSwitch): """Representation of one zone of sprinklers connected to the Rachio Iro.""" + _attr_icon = "mdi:water" + def __init__(self, person, controller, data, current_schedule): """Initialize a new Rachio Zone.""" self.id = data[KEY_ID] - self._zone_name = data[KEY_NAME] + self._attr_name = data[KEY_NAME] self._zone_number = data[KEY_ZONE_NUMBER] self._zone_enabled = data[KEY_ENABLED] - self._entity_picture = data.get(KEY_IMAGE_URL) + self._attr_entity_picture = data.get(KEY_IMAGE_URL) self._person = person self._shade_type = data.get(KEY_CUSTOM_SHADE, {}).get(KEY_NAME) self._zone_type = data.get(KEY_CUSTOM_CROP, {}).get(KEY_NAME) self._slope_type = data.get(KEY_CUSTOM_SLOPE, {}).get(KEY_NAME) self._summary = "" self._current_schedule = current_schedule + self._attr_unique_id = f"{controller.controller_id}-zone-{self.id}" super().__init__(controller) def __str__(self): @@ -354,31 +347,11 @@ def zone_id(self) -> str: """How the Rachio API refers to the zone.""" return self.id - @property - def name(self) -> str: - """Return the friendly name of the zone.""" - return self._zone_name - - @property - def unique_id(self) -> str: - """Return a unique id by combining controller id and zone number.""" - return f"{self._controller.controller_id}-zone-{self.zone_id}" - - @property - def icon(self) -> str: - """Return the icon to display.""" - return "mdi:water" - @property def zone_is_enabled(self) -> bool: """Return whether the zone is allowed to run.""" return self._zone_enabled - @property - def entity_picture(self): - """Return the entity picture to use in the frontend, if any.""" - return self._entity_picture - @property def extra_state_attributes(self) -> dict[str, Any]: """Return the optional state attributes.""" @@ -424,7 +397,7 @@ def turn_off(self, **kwargs: Any) -> None: def set_moisture_percent(self, percent) -> None: """Set the zone moisture percent.""" - _LOGGER.debug("Setting %s moisture to %s percent", self._zone_name, percent) + _LOGGER.debug("Setting %s moisture to %s percent", self.name, percent) self._controller.rachio.zone.set_moisture_percent(self.id, percent / 100) @callback @@ -436,19 +409,19 @@ def _async_handle_update(self, *args, **kwargs) -> None: self._summary = args[0][KEY_SUMMARY] if args[0][KEY_SUBTYPE] == SUBTYPE_ZONE_STARTED: - self._state = True + self._attr_is_on = True elif args[0][KEY_SUBTYPE] in [ SUBTYPE_ZONE_STOPPED, SUBTYPE_ZONE_COMPLETED, SUBTYPE_ZONE_PAUSED, ]: - self._state = False + self._attr_is_on = False self.async_write_ha_state() async def async_added_to_hass(self) -> None: """Subscribe to updates.""" - self._state = self.zone_id == self._current_schedule.get(KEY_ZONE_ID) + self._attr_is_on = self.zone_id == self._current_schedule.get(KEY_ZONE_ID) self.async_on_remove( async_dispatcher_connect( @@ -463,24 +436,17 @@ class RachioSchedule(RachioSwitch): def __init__(self, person, controller, data, current_schedule): """Initialize a new Rachio Schedule.""" self._schedule_id = data[KEY_ID] - self._schedule_name = data[KEY_NAME] self._duration = data[KEY_DURATION] self._schedule_enabled = data[KEY_ENABLED] self._summary = data[KEY_SUMMARY] self.type = data.get(KEY_TYPE, SCHEDULE_TYPE_FIXED) self._current_schedule = current_schedule + self._attr_unique_id = ( + f"{controller.controller_id}-schedule-{self._schedule_id}" + ) + self._attr_name = f"{data[KEY_NAME]} Schedule" super().__init__(controller) - @property - def name(self) -> str: - """Return the friendly name of the schedule.""" - return f"{self._schedule_name} Schedule" - - @property - def unique_id(self) -> str: - """Return a unique id by combining controller id and schedule.""" - return f"{self._controller.controller_id}-schedule-{self._schedule_id}" - @property def icon(self) -> str: """Return the icon to display.""" @@ -521,18 +487,20 @@ def _async_handle_update(self, *args, **kwargs) -> None: with suppress(KeyError): if args[0][KEY_SCHEDULE_ID] == self._schedule_id: if args[0][KEY_SUBTYPE] in [SUBTYPE_SCHEDULE_STARTED]: - self._state = True + self._attr_is_on = True elif args[0][KEY_SUBTYPE] in [ SUBTYPE_SCHEDULE_STOPPED, SUBTYPE_SCHEDULE_COMPLETED, ]: - self._state = False + self._attr_is_on = False self.async_write_ha_state() async def async_added_to_hass(self) -> None: """Subscribe to updates.""" - self._state = self._schedule_id == self._current_schedule.get(KEY_SCHEDULE_ID) + self._attr_is_on = self._schedule_id == self._current_schedule.get( + KEY_SCHEDULE_ID + ) self.async_on_remove( async_dispatcher_connect( diff --git a/homeassistant/components/rainbird/coordinator.py b/homeassistant/components/rainbird/coordinator.py index d81b942d669d67..cac86d8c928f1e 100644 --- a/homeassistant/components/rainbird/coordinator.py +++ b/homeassistant/components/rainbird/coordinator.py @@ -54,7 +54,6 @@ def __init__( hass, _LOGGER, name=name, - update_method=self._async_update_data, update_interval=UPDATE_INTERVAL, ) self._controller = controller diff --git a/homeassistant/components/rainbird/switch.py b/homeassistant/components/rainbird/switch.py index ac42e00c676084..39bb4a7b0d1caa 100644 --- a/homeassistant/components/rainbird/switch.py +++ b/homeassistant/components/rainbird/switch.py @@ -71,7 +71,6 @@ def __init__( else: self._attr_name = None self._attr_has_entity_name = True - self._state = None self._duration_minutes = duration_minutes self._attr_unique_id = f"{coordinator.serial_number}-{zone}" self._attr_device_info = DeviceInfo( diff --git a/homeassistant/components/rainforest_eagle/sensor.py b/homeassistant/components/rainforest_eagle/sensor.py index 113cfceb7d635b..987142c6390f45 100644 --- a/homeassistant/components/rainforest_eagle/sensor.py +++ b/homeassistant/components/rainforest_eagle/sensor.py @@ -75,11 +75,13 @@ def __init__(self, coordinator, entity_description): """Initialize the sensor.""" super().__init__(coordinator) self.entity_description = entity_description - - @property - def unique_id(self) -> str | None: - """Return unique ID of entity.""" - return f"{self.coordinator.cloud_id}-${self.coordinator.hardware_address}-{self.entity_description.key}" + self._attr_unique_id = f"{coordinator.cloud_id}-${coordinator.hardware_address}-{entity_description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.cloud_id)}, + manufacturer="Rainforest Automation", + model=coordinator.model, + name=coordinator.model, + ) @property def available(self) -> bool: @@ -90,13 +92,3 @@ def available(self) -> bool: def native_value(self) -> StateType: """Return native value of the sensor.""" return self.coordinator.data.get(self.entity_description.key) - - @property - def device_info(self) -> DeviceInfo: - """Return device info.""" - return DeviceInfo( - identifiers={(DOMAIN, self.coordinator.cloud_id)}, - manufacturer="Rainforest Automation", - model=self.coordinator.model, - name=self.coordinator.model, - ) diff --git a/homeassistant/components/rainmachine/sensor.py b/homeassistant/components/rainmachine/sensor.py index 6333dcc82f4699..bdae62c1bd8d30 100644 --- a/homeassistant/components/rainmachine/sensor.py +++ b/homeassistant/components/rainmachine/sensor.py @@ -141,7 +141,6 @@ class RainMachineSensorCompletionTimerDescription( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, device_class=SensorDeviceClass.TIMESTAMP, - state_class=SensorStateClass.MEASUREMENT, api_category=DATA_PROVISION_SETTINGS, data_key="lastLeakDetected", ), @@ -152,7 +151,6 @@ class RainMachineSensorCompletionTimerDescription( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, device_class=SensorDeviceClass.TIMESTAMP, - state_class=SensorStateClass.MEASUREMENT, api_category=DATA_PROVISION_SETTINGS, data_key="rainSensorRainStart", ), diff --git a/homeassistant/components/recollect_waste/__init__.py b/homeassistant/components/recollect_waste/__init__.py index 21cf574d548b69..076067312eb63b 100644 --- a/homeassistant/components/recollect_waste/__init__.py +++ b/homeassistant/components/recollect_waste/__init__.py @@ -1,7 +1,7 @@ """The ReCollect Waste integration.""" from __future__ import annotations -from datetime import timedelta +from datetime import date, timedelta from typing import Any from aiorecollect.client import Client, PickupEvent @@ -31,7 +31,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_get_pickup_events() -> list[PickupEvent]: """Get the next pickup.""" try: - return await client.async_get_pickup_events() + # Retrieve today through to 35 days in the future, to get + # coverage across a full two months boundary so that no + # upcoming pickups are missed. The api.recollect.net base API + # call returns only the current month when no dates are passed. + # This ensures that data about when the next pickup is will be + # returned when the next pickup is the first day of the next month. + # Ex: Today is August 31st, tomorrow is a pickup on September 1st. + today = date.today() + return await client.async_get_pickup_events( + start_date=today, + end_date=today + timedelta(days=35), + ) except RecollectError as err: raise UpdateFailed( f"Error while requesting data from ReCollect: {err}" diff --git a/homeassistant/components/recollect_waste/manifest.json b/homeassistant/components/recollect_waste/manifest.json index dc31adddb782aa..e1ad3f989501dc 100644 --- a/homeassistant/components/recollect_waste/manifest.json +++ b/homeassistant/components/recollect_waste/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["aiorecollect"], - "requirements": ["aiorecollect==1.0.8"] + "requirements": ["aiorecollect==2023.09.0"] } diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index ffdc3807039d3a..8aa2bce96b1bf2 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -187,7 +187,7 @@ def __init__( self.auto_purge = auto_purge self.auto_repack = auto_repack self.keep_days = keep_days - self._hass_started: asyncio.Future[object] = asyncio.Future() + self._hass_started: asyncio.Future[object] = hass.loop.create_future() self.commit_interval = commit_interval self._queue: queue.SimpleQueue[RecorderTask] = queue.SimpleQueue() self.db_url = uri @@ -198,7 +198,7 @@ def __init__( db_connected: asyncio.Future[bool] = hass.data[DOMAIN].db_connected self.async_db_connected: asyncio.Future[bool] = db_connected # Database is ready to use but live migration may be in progress - self.async_db_ready: asyncio.Future[bool] = asyncio.Future() + self.async_db_ready: asyncio.Future[bool] = hass.loop.create_future() # Database is ready to use and all migration steps completed (used by tests) self.async_recorder_ready = asyncio.Event() self._queue_watch = threading.Event() @@ -692,6 +692,10 @@ def run(self) -> None: """Run the recorder thread.""" try: self._run() + except Exception: # pylint: disable=broad-exception-caught + _LOGGER.exception( + "Recorder._run threw unexpected exception, recorder shutting down" + ) finally: # Ensure shutdown happens cleanly if # anything goes wrong in the run loop diff --git a/homeassistant/components/recorder/db_schema.py b/homeassistant/components/recorder/db_schema.py index 508874c54e56f0..e992a683cb13cd 100644 --- a/homeassistant/components/recorder/db_schema.py +++ b/homeassistant/components/recorder/db_schema.py @@ -40,6 +40,7 @@ MAX_LENGTH_STATE_STATE, ) from homeassistant.core import Context, Event, EventOrigin, State, split_entity_id +from homeassistant.helpers.entity import EntityInfo from homeassistant.helpers.json import JSON_DUMP, json_bytes, json_bytes_strip_null import homeassistant.util.dt as dt_util from homeassistant.util.json import ( @@ -558,7 +559,7 @@ def __repr__(self) -> str: @staticmethod def shared_attrs_bytes_from_event( event: Event, - entity_sources: dict[str, dict[str, str]], + entity_sources: dict[str, EntityInfo], exclude_attrs_by_domain: dict[str, set[str]], dialect: SupportedDialect | None, ) -> bytes: @@ -575,6 +576,8 @@ def shared_attrs_bytes_from_event( integration_attrs := exclude_attrs_by_domain.get(entity_info["domain"]) ): exclude_attrs |= integration_attrs + if state_info := state.state_info: + exclude_attrs |= state_info["unrecorded_attributes"] encoder = json_bytes_strip_null if dialect == PSQL_DIALECT else json_bytes bytes_result = encoder( {k: v for k, v in state.attributes.items() if k not in exclude_attrs} diff --git a/homeassistant/components/recorder/history/legacy.py b/homeassistant/components/recorder/history/legacy.py index 191c74ac0d44d0..2e1b02a8b64325 100644 --- a/homeassistant/components/recorder/history/legacy.py +++ b/homeassistant/components/recorder/history/legacy.py @@ -50,7 +50,7 @@ States.last_changed_ts, States.last_updated_ts, ) -_BASE_STATES_NO_LAST_CHANGED = ( # type: ignore[var-annotated] +_BASE_STATES_NO_LAST_CHANGED = ( States.entity_id, States.state, literal(value=None).label("last_changed_ts"), diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json index 63b19cdb3bfb59..f40797fe38cf69 100644 --- a/homeassistant/components/recorder/manifest.json +++ b/homeassistant/components/recorder/manifest.json @@ -7,7 +7,7 @@ "iot_class": "local_push", "quality_scale": "internal", "requirements": [ - "SQLAlchemy==2.0.15", + "SQLAlchemy==2.0.21", "fnv-hash-fast==0.4.1", "psutil-home-assistant==0.0.1" ] diff --git a/homeassistant/components/renson/__init__.py b/homeassistant/components/renson/__init__.py index 86dfdc1f18bd79..2a9c13be543636 100644 --- a/homeassistant/components/renson/__init__.py +++ b/homeassistant/components/renson/__init__.py @@ -1,11 +1,7 @@ """The Renson integration.""" from __future__ import annotations -import asyncio from dataclasses import dataclass -from datetime import timedelta -import logging -from typing import Any from renson_endura_delta.renson import RensonVentilation @@ -13,14 +9,15 @@ from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN - -_LOGGER = logging.getLogger(__name__) +from .coordinator import RensonCoordinator PLATFORMS = [ Platform.BINARY_SENSOR, + Platform.BUTTON, + Platform.FAN, + Platform.NUMBER, Platform.SENSOR, ] @@ -60,30 +57,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -class RensonCoordinator(DataUpdateCoordinator): - """Data update coordinator for Renson.""" - - def __init__( - self, - name: str, - hass: HomeAssistant, - api: RensonVentilation, - update_interval=timedelta(seconds=30), - ) -> None: - """Initialize my coordinator.""" - super().__init__( - hass, - _LOGGER, - # Name of the data. For logging purposes. - name=name, - # Polling interval. Will only be polled if there are subscribers. - update_interval=update_interval, - ) - self.api = api - - async def _async_update_data(self) -> dict[str, Any]: - """Fetch data from API endpoint.""" - async with asyncio.timeout(30): - return await self.hass.async_add_executor_job(self.api.get_all_data) diff --git a/homeassistant/components/renson/binary_sensor.py b/homeassistant/components/renson/binary_sensor.py index cad8b92c0c3948..39c2b1b883d342 100644 --- a/homeassistant/components/renson/binary_sensor.py +++ b/homeassistant/components/renson/binary_sensor.py @@ -25,8 +25,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import RensonCoordinator from .const import DOMAIN +from .coordinator import RensonCoordinator from .entity import RensonEntity diff --git a/homeassistant/components/renson/button.py b/homeassistant/components/renson/button.py new file mode 100644 index 00000000000000..53d995ba792864 --- /dev/null +++ b/homeassistant/components/renson/button.py @@ -0,0 +1,90 @@ +"""Renson ventilation unit buttons.""" +from __future__ import annotations + +from dataclasses import dataclass + +from _collections_abc import Callable +from renson_endura_delta.renson import RensonVentilation + +from homeassistant.components.button import ( + ButtonDeviceClass, + ButtonEntity, + ButtonEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import RensonCoordinator, RensonData +from .const import DOMAIN +from .entity import RensonEntity + + +@dataclass +class RensonButtonEntityDescriptionMixin: + """Action function called on press.""" + + action_fn: Callable[[RensonVentilation], None] + + +@dataclass +class RensonButtonEntityDescription( + ButtonEntityDescription, RensonButtonEntityDescriptionMixin +): + """Class describing Renson button entity.""" + + +ENTITY_DESCRIPTIONS: tuple[RensonButtonEntityDescription, ...] = ( + RensonButtonEntityDescription( + key="sync_time", + entity_category=EntityCategory.CONFIG, + translation_key="sync_time", + action_fn=lambda api: api.sync_time(), + ), + RensonButtonEntityDescription( + key="restart", + device_class=ButtonDeviceClass.RESTART, + entity_category=EntityCategory.CONFIG, + action_fn=lambda api: api.restart_device(), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Renson button platform.""" + + data: RensonData = hass.data[DOMAIN][config_entry.entry_id] + + entities = [ + RensonButton(description, data.api, data.coordinator) + for description in ENTITY_DESCRIPTIONS + ] + + async_add_entities(entities) + + +class RensonButton(RensonEntity, ButtonEntity): + """Representation of a Renson actions.""" + + _attr_has_entity_name = True + entity_description: RensonButtonEntityDescription + + def __init__( + self, + description: RensonButtonEntityDescription, + api: RensonVentilation, + coordinator: RensonCoordinator, + ) -> None: + """Initialize class.""" + super().__init__(description.key, api, coordinator) + + self.entity_description = description + + def press(self) -> None: + """Triggers the action.""" + self.entity_description.action_fn(self.api) diff --git a/homeassistant/components/renson/coordinator.py b/homeassistant/components/renson/coordinator.py new file mode 100644 index 00000000000000..924a3b765f56ba --- /dev/null +++ b/homeassistant/components/renson/coordinator.py @@ -0,0 +1,41 @@ +"""DataUpdateCoordinator for the renson integration.""" +from __future__ import annotations + +import asyncio +from datetime import timedelta +import logging +from typing import Any + +from renson_endura_delta.renson import RensonVentilation + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +class RensonCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Data update coordinator for Renson.""" + + def __init__( + self, + name: str, + hass: HomeAssistant, + api: RensonVentilation, + update_interval=timedelta(seconds=30), + ) -> None: + """Initialize my coordinator.""" + super().__init__( + hass, + _LOGGER, + # Name of the data. For logging purposes. + name=name, + # Polling interval. Will only be polled if there are subscribers. + update_interval=update_interval, + ) + self.api = api + + async def _async_update_data(self) -> dict[str, Any]: + """Fetch data from API endpoint.""" + async with asyncio.timeout(30): + return await self.hass.async_add_executor_job(self.api.get_all_data) diff --git a/homeassistant/components/renson/entity.py b/homeassistant/components/renson/entity.py index 245b55d661196b..9bb2c27b112276 100644 --- a/homeassistant/components/renson/entity.py +++ b/homeassistant/components/renson/entity.py @@ -12,8 +12,8 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import RensonCoordinator from .const import DOMAIN +from .coordinator import RensonCoordinator class RensonEntity(CoordinatorEntity[RensonCoordinator]): diff --git a/homeassistant/components/renson/fan.py b/homeassistant/components/renson/fan.py new file mode 100644 index 00000000000000..da6850859a6696 --- /dev/null +++ b/homeassistant/components/renson/fan.py @@ -0,0 +1,118 @@ +"""Platform to control a Renson ventilation unit.""" +from __future__ import annotations + +import logging +import math +from typing import Any + +from renson_endura_delta.field_enum import CURRENT_LEVEL_FIELD, DataType +from renson_endura_delta.renson import Level, RensonVentilation + +from homeassistant.components.fan import FanEntity, FanEntityFeature +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util.percentage import ( + int_states_in_range, + percentage_to_ranged_value, + ranged_value_to_percentage, +) + +from .const import DOMAIN +from .coordinator import RensonCoordinator +from .entity import RensonEntity + +_LOGGER = logging.getLogger(__name__) + +CMD_MAPPING = { + 0: Level.HOLIDAY, + 1: Level.LEVEL1, + 2: Level.LEVEL2, + 3: Level.LEVEL3, + 4: Level.LEVEL4, +} + +SPEED_MAPPING = { + Level.OFF.value: 0, + Level.HOLIDAY.value: 0, + Level.LEVEL1.value: 1, + Level.LEVEL2.value: 2, + Level.LEVEL3.value: 3, + Level.LEVEL4.value: 4, +} + + +SPEED_RANGE: tuple[float, float] = (1, 4) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Renson fan platform.""" + + api: RensonVentilation = hass.data[DOMAIN][config_entry.entry_id].api + coordinator: RensonCoordinator = hass.data[DOMAIN][ + config_entry.entry_id + ].coordinator + + async_add_entities([RensonFan(api, coordinator)]) + + +class RensonFan(RensonEntity, FanEntity): + """Representation of the Renson fan platform.""" + + _attr_icon = "mdi:air-conditioner" + _attr_has_entity_name = True + _attr_name = None + _attr_supported_features = FanEntityFeature.SET_SPEED + + def __init__(self, api: RensonVentilation, coordinator: RensonCoordinator) -> None: + """Initialize the Renson fan.""" + super().__init__("fan", api, coordinator) + self._attr_speed_count = int_states_in_range(SPEED_RANGE) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + level = self.api.parse_value( + self.api.get_field_value(self.coordinator.data, CURRENT_LEVEL_FIELD.name), + DataType.LEVEL, + ) + + self._attr_percentage = ranged_value_to_percentage( + SPEED_RANGE, SPEED_MAPPING[level] + ) + + super()._handle_coordinator_update() + + async def async_turn_on( + self, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, + ) -> None: + """Turn on the fan.""" + if percentage is None: + percentage = 1 + + await self.async_set_percentage(percentage) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the fan (to away).""" + await self.async_set_percentage(0) + + async def async_set_percentage(self, percentage: int) -> None: + """Set fan speed percentage.""" + _LOGGER.debug("Changing fan speed percentage to %s", percentage) + + if percentage == 0: + cmd = Level.HOLIDAY + else: + speed = math.ceil(percentage_to_ranged_value(SPEED_RANGE, percentage)) + cmd = CMD_MAPPING[speed] + + await self.hass.async_add_executor_job(self.api.set_manual_level, cmd) + + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/renson/manifest.json b/homeassistant/components/renson/manifest.json index 5ff219cc26c880..1a7f367a9464db 100644 --- a/homeassistant/components/renson/manifest.json +++ b/homeassistant/components/renson/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/renson", "iot_class": "local_polling", - "requirements": ["renson-endura-delta==1.5.0"] + "requirements": ["renson-endura-delta==1.6.0"] } diff --git a/homeassistant/components/renson/number.py b/homeassistant/components/renson/number.py new file mode 100644 index 00000000000000..344fa3ff0bd984 --- /dev/null +++ b/homeassistant/components/renson/number.py @@ -0,0 +1,84 @@ +"""Platform to control a Renson ventilation unit.""" +from __future__ import annotations + +import logging + +from renson_endura_delta.field_enum import FILTER_PRESET_FIELD, DataType +from renson_endura_delta.renson import RensonVentilation + +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory, UnitOfTime +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import RensonCoordinator +from .entity import RensonEntity + +_LOGGER = logging.getLogger(__name__) + + +RENSON_NUMBER_DESCRIPTION = NumberEntityDescription( + key="filter_change", + translation_key="filter_change", + icon="mdi:filter", + native_step=1, + native_min_value=0, + native_max_value=360, + entity_category=EntityCategory.CONFIG, + has_entity_name=True, + device_class=NumberDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.DAYS, +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Renson number platform.""" + + api: RensonVentilation = hass.data[DOMAIN][config_entry.entry_id].api + coordinator: RensonCoordinator = hass.data[DOMAIN][ + config_entry.entry_id + ].coordinator + + async_add_entities([RensonNumber(RENSON_NUMBER_DESCRIPTION, api, coordinator)]) + + +class RensonNumber(RensonEntity, NumberEntity): + """Representation of the Renson number platform.""" + + def __init__( + self, + description: NumberEntityDescription, + api: RensonVentilation, + coordinator: RensonCoordinator, + ) -> None: + """Initialize the Renson number.""" + super().__init__(description.key, api, coordinator) + + self.entity_description = description + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._attr_native_value = self.api.parse_value( + self.api.get_field_value(self.coordinator.data, FILTER_PRESET_FIELD.name), + DataType.NUMERIC, + ) + + super()._handle_coordinator_update() + + async def async_set_native_value(self, value: float) -> None: + """Update the current value.""" + + await self.hass.async_add_executor_job(self.api.set_filter_days, value) + + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/renson/sensor.py b/homeassistant/components/renson/sensor.py index c8a355a0f7c86a..b729e2969d634b 100644 --- a/homeassistant/components/renson/sensor.py +++ b/homeassistant/components/renson/sensor.py @@ -46,8 +46,9 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import RensonCoordinator, RensonData +from . import RensonData from .const import DOMAIN +from .coordinator import RensonCoordinator from .entity import RensonEntity OPTIONS_MAPPING = { @@ -266,6 +267,8 @@ class RensonSensorEntityDescription( class RensonSensor(RensonEntity, SensorEntity): """Get a sensor data from the Renson API and store it in the state of the class.""" + _attr_has_entity_name = True + def __init__( self, description: RensonSensorEntityDescription, diff --git a/homeassistant/components/renson/strings.json b/homeassistant/components/renson/strings.json index 20db9e788b8c77..7099cdf2c4587e 100644 --- a/homeassistant/components/renson/strings.json +++ b/homeassistant/components/renson/strings.json @@ -13,6 +13,16 @@ } }, "entity": { + "button": { + "sync_time": { + "name": "Sync time with device" + } + }, + "number": { + "filter_change": { + "name": "Filter clean/replacement" + } + }, "binary_sensor": { "frost_protection_active": { "name": "Frost protection active" diff --git a/homeassistant/components/reolink/config_flow.py b/homeassistant/components/reolink/config_flow.py index d24fd8d1f14ba6..e86da1f23a7d34 100644 --- a/homeassistant/components/reolink/config_flow.py +++ b/homeassistant/components/reolink/config_flow.py @@ -12,13 +12,14 @@ from homeassistant.components import dhcp from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.core import callback -from homeassistant.data_entry_flow import FlowResult +from homeassistant.data_entry_flow import AbortFlow, FlowResult from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device_registry import format_mac from .const import CONF_PROTOCOL, CONF_USE_HTTPS, DOMAIN from .exceptions import ReolinkException, ReolinkWebhookException, UserNotAdmin from .host import ReolinkHost +from .util import is_connected _LOGGER = logging.getLogger(__name__) @@ -96,7 +97,46 @@ async def async_step_reauth_confirm( async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult: """Handle discovery via dhcp.""" mac_address = format_mac(discovery_info.macaddress) - await self.async_set_unique_id(mac_address) + existing_entry = await self.async_set_unique_id(mac_address) + if ( + existing_entry + and CONF_PASSWORD in existing_entry.data + and existing_entry.data[CONF_HOST] != discovery_info.ip + ): + if is_connected(self.hass, existing_entry): + _LOGGER.debug( + "Reolink DHCP reported new IP '%s', " + "but connection to camera seems to be okay, so sticking to IP '%s'", + discovery_info.ip, + existing_entry.data[CONF_HOST], + ) + raise AbortFlow("already_configured") + + # check if the camera is reachable at the new IP + host = ReolinkHost(self.hass, existing_entry.data, existing_entry.options) + try: + await host.api.get_state("GetLocalLink") + await host.api.logout() + except ReolinkError as err: + _LOGGER.debug( + "Reolink DHCP reported new IP '%s', " + "but got error '%s' trying to connect, so sticking to IP '%s'", + discovery_info.ip, + str(err), + existing_entry.data[CONF_HOST], + ) + raise AbortFlow("already_configured") from err + if format_mac(host.api.mac_address) != mac_address: + _LOGGER.debug( + "Reolink mac address '%s' at new IP '%s' from DHCP, " + "does not match mac '%s' of config entry, so sticking to IP '%s'", + format_mac(host.api.mac_address), + discovery_info.ip, + mac_address, + existing_entry.data[CONF_HOST], + ) + raise AbortFlow("already_configured") + self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.ip}) self.context["title_placeholders"] = { diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index a679cb34f4bc22..2487013b032726 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -10,7 +10,7 @@ from aiohttp.web import Request from reolink_aio.api import Host from reolink_aio.enums import SubType -from reolink_aio.exceptions import ReolinkError, SubscriptionError +from reolink_aio.exceptions import NotSupportedError, ReolinkError, SubscriptionError from homeassistant.components import webhook from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME @@ -61,6 +61,8 @@ def __init__( ) self.webhook_id: str | None = None + self._onvif_push_supported: bool = True + self._onvif_long_poll_supported: bool = True self._base_url: str = "" self._webhook_url: str = "" self._webhook_reachable: bool = False @@ -96,6 +98,10 @@ async def async_init(self) -> None: f"'{self._api.user_level}', only admin users can change camera settings" ) + onvif_supported = self._api.supported(None, "ONVIF") + self._onvif_push_supported = onvif_supported + self._onvif_long_poll_supported = onvif_supported + enable_rtsp = None enable_onvif = None enable_rtmp = None @@ -106,7 +112,7 @@ async def async_init(self) -> None: ) enable_rtsp = True - if not self._api.onvif_enabled: + if not self._api.onvif_enabled and onvif_supported: _LOGGER.debug( "ONVIF is disabled on %s, trying to enable it", self._api.nvr_name ) @@ -154,21 +160,49 @@ async def async_init(self) -> None: self._unique_id = format_mac(self._api.mac_address) - await self.subscribe() - - if self._api.supported(None, "initial_ONVIF_state"): - _LOGGER.debug( - "Waiting for initial ONVIF state on webhook '%s'", self._webhook_url - ) - else: + if self._onvif_push_supported: + try: + await self.subscribe() + except NotSupportedError: + self._onvif_push_supported = False + self.unregister_webhook() + await self._api.unsubscribe() + else: + if self._api.supported(None, "initial_ONVIF_state"): + _LOGGER.debug( + "Waiting for initial ONVIF state on webhook '%s'", + self._webhook_url, + ) + else: + _LOGGER.debug( + "Camera model %s most likely does not push its initial state" + " upon ONVIF subscription, do not check", + self._api.model, + ) + self._cancel_onvif_check = async_call_later( + self._hass, FIRST_ONVIF_TIMEOUT, self._async_check_onvif + ) + if not self._onvif_push_supported: _LOGGER.debug( - "Camera model %s most likely does not push its initial state" - " upon ONVIF subscription, do not check", + "Camera model %s does not support ONVIF push, using ONVIF long polling instead", self._api.model, ) - self._cancel_onvif_check = async_call_later( - self._hass, FIRST_ONVIF_TIMEOUT, self._async_check_onvif - ) + try: + await self._async_start_long_polling(initial=True) + except NotSupportedError: + _LOGGER.debug( + "Camera model %s does not support ONVIF long polling, using fast polling instead", + self._api.model, + ) + self._onvif_long_poll_supported = False + await self._api.unsubscribe() + await self._async_poll_all_motion() + else: + self._cancel_long_poll_check = async_call_later( + self._hass, + FIRST_ONVIF_LONG_POLL_TIMEOUT, + self._async_check_onvif_long_poll, + ) if self._api.sw_version_update_required: ir.async_create_issue( @@ -301,11 +335,22 @@ async def disconnect(self): str(err), ) - async def _async_start_long_polling(self): + async def _async_start_long_polling(self, initial=False): """Start ONVIF long polling task.""" if self._long_poll_task is None: try: await self._api.subscribe(sub_type=SubType.long_poll) + except NotSupportedError as err: + if initial: + raise err + # make sure the long_poll_task is always created to try again later + if not self._lost_subscription: + self._lost_subscription = True + _LOGGER.error( + "Reolink %s event long polling subscription lost: %s", + self._api.nvr_name, + str(err), + ) except ReolinkError as err: # make sure the long_poll_task is always created to try again later if not self._lost_subscription: @@ -366,8 +411,10 @@ async def subscribe(self) -> None: async def renew(self) -> None: """Renew the subscription of motion events (lease time is 15 minutes).""" try: - await self._renew(SubType.push) - if self._long_poll_task is not None: + if self._onvif_push_supported: + await self._renew(SubType.push) + + if self._onvif_long_poll_supported and self._long_poll_task is not None: if not self._api.subscribed(SubType.long_poll): _LOGGER.debug("restarting long polling task") # To prevent 5 minute request timeout diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 3ff25d1e7a0910..221a6b8b59d627 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -18,5 +18,5 @@ "documentation": "https://www.home-assistant.io/integrations/reolink", "iot_class": "local_push", "loggers": ["reolink_aio"], - "requirements": ["reolink-aio==0.7.8"] + "requirements": ["reolink-aio==0.7.10"] } diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 95aa26a1ff53d9..15ba4baed45733 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -223,6 +223,7 @@ "state": { "off": "[%key:common::state::off%]", "auto": "Auto", + "onatnight": "On at night", "schedule": "Schedule", "adaptive": "Adaptive", "autoadaptive": "Auto adaptive" diff --git a/homeassistant/components/reolink/util.py b/homeassistant/components/reolink/util.py new file mode 100644 index 00000000000000..cc9ad192bc3a1a --- /dev/null +++ b/homeassistant/components/reolink/util.py @@ -0,0 +1,20 @@ +"""Utility functions for the Reolink component.""" +from __future__ import annotations + +from homeassistant import config_entries +from homeassistant.core import HomeAssistant + +from . import ReolinkData +from .const import DOMAIN + + +def is_connected(hass: HomeAssistant, config_entry: config_entries.ConfigEntry) -> bool: + """Check if an existing entry has a proper connection.""" + reolink_data: ReolinkData | None = hass.data.get(DOMAIN, {}).get( + config_entry.entry_id + ) + return ( + reolink_data is not None + and config_entry.state == config_entries.ConfigEntryState.LOADED + and reolink_data.device_coordinator.last_update_success + ) diff --git a/homeassistant/components/rest/manifest.json b/homeassistant/components/rest/manifest.json index c8796c7161cd79..d638c20d2a4e59 100644 --- a/homeassistant/components/rest/manifest.json +++ b/homeassistant/components/rest/manifest.json @@ -4,5 +4,5 @@ "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/rest", "iot_class": "local_polling", - "requirements": ["jsonpath==0.82", "xmltodict==0.13.0"] + "requirements": ["jsonpath==0.82.2", "xmltodict==0.13.0"] } diff --git a/homeassistant/components/rflink/sensor.py b/homeassistant/components/rflink/sensor.py index b96e03e7eb4297..fd6db8f0c6047a 100644 --- a/homeassistant/components/rflink/sensor.py +++ b/homeassistant/components/rflink/sensor.py @@ -352,6 +352,7 @@ def _handle_event(self, event): """Domain specific event handler.""" self._state = event["value"] + # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: """Register update callback.""" # Remove temporary bogus entity_id if added diff --git a/homeassistant/components/ring/camera.py b/homeassistant/components/ring/camera.py index 0b3f1509b189f8..7f897d172035d1 100644 --- a/homeassistant/components/ring/camera.py +++ b/homeassistant/components/ring/camera.py @@ -60,6 +60,7 @@ def __init__(self, config_entry_id, ffmpeg_manager, device): self._video_url = None self._image = None self._expires_at = dt_util.utcnow() - FORCE_REFRESH_INTERVAL + self._attr_unique_id = device.id async def async_added_to_hass(self) -> None: """Register callbacks.""" @@ -91,11 +92,6 @@ def _history_update_callback(self, history_data): self._expires_at = dt_util.utcnow() self.async_write_ha_state() - @property - def unique_id(self): - """Return a unique ID.""" - return self._device.id - @property def extra_state_attributes(self): """Return the state attributes.""" diff --git a/homeassistant/components/ring/entity.py b/homeassistant/components/ring/entity.py index 2b345b3b703ab4..7160d2ef7259bd 100644 --- a/homeassistant/components/ring/entity.py +++ b/homeassistant/components/ring/entity.py @@ -19,6 +19,12 @@ def __init__(self, config_entry_id, device): self._config_entry_id = config_entry_id self._device = device self._attr_extra_state_attributes = {} + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device.device_id)}, + manufacturer="Ring", + model=device.model, + name=device.name, + ) async def async_added_to_hass(self) -> None: """Register callbacks.""" @@ -37,13 +43,3 @@ def _update_callback(self) -> None: def ring_objects(self): """Return the Ring API objects.""" return self.hass.data[DOMAIN][self._config_entry_id] - - @property - def device_info(self) -> DeviceInfo: - """Return device info.""" - return DeviceInfo( - identifiers={(DOMAIN, self._device.device_id)}, - manufacturer="Ring", - model=self._device.model, - name=self._device.name, - ) diff --git a/homeassistant/components/ring/light.py b/homeassistant/components/ring/light.py index 2604e557b79b4d..93640e2764e6b5 100644 --- a/homeassistant/components/ring/light.py +++ b/homeassistant/components/ring/light.py @@ -55,8 +55,8 @@ class RingLight(RingEntityMixin, LightEntity): def __init__(self, config_entry_id, device): """Initialize the light.""" super().__init__(config_entry_id, device) - self._unique_id = device.id - self._light_on = device.lights == ON_STATE + self._attr_unique_id = device.id + self._attr_is_on = device.lights == ON_STATE self._no_updates_until = dt_util.utcnow() @callback @@ -65,19 +65,9 @@ def _update_callback(self): if self._no_updates_until > dt_util.utcnow(): return - self._light_on = self._device.lights == ON_STATE + self._attr_is_on = self._device.lights == ON_STATE self.async_write_ha_state() - @property - def unique_id(self): - """Return a unique ID.""" - return self._unique_id - - @property - def is_on(self): - """If the switch is currently on or off.""" - return self._light_on - def _set_light(self, new_state): """Update light state, and causes Home Assistant to correctly update.""" try: @@ -86,7 +76,7 @@ def _set_light(self, new_state): _LOGGER.error("Time out setting %s light to %s", self.entity_id, new_state) return - self._light_on = new_state == ON_STATE + self._attr_is_on = new_state == ON_STATE self._no_updates_until = dt_util.utcnow() + SKIP_UPDATES_DELAY self.async_write_ha_state() diff --git a/homeassistant/components/ring/sensor.py b/homeassistant/components/ring/sensor.py index fbaeb8a4b5b529..af23af07ebab18 100644 --- a/homeassistant/components/ring/sensor.py +++ b/homeassistant/components/ring/sensor.py @@ -68,6 +68,9 @@ def native_value(self): class HealthDataRingSensor(RingSensor): """Ring sensor that relies on health data.""" + # These sensors are data hungry and not useful. Disable by default. + _attr_entity_registry_enabled_default = False + async def async_added_to_hass(self) -> None: """Register callbacks.""" await super().async_added_to_hass() @@ -89,12 +92,6 @@ def _health_update_callback(self, _health_data): """Call update method.""" self.async_write_ha_state() - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - # These sensors are data hungry and not useful. Disable by default. - return False - @property def native_value(self): """Return the state of the sensor.""" diff --git a/homeassistant/components/ring/switch.py b/homeassistant/components/ring/switch.py index 43bd303577a13b..7069acd5f0feb1 100644 --- a/homeassistant/components/ring/switch.py +++ b/homeassistant/components/ring/switch.py @@ -50,24 +50,20 @@ def __init__(self, config_entry_id, device, device_type): """Initialize the switch.""" super().__init__(config_entry_id, device) self._device_type = device_type - self._unique_id = f"{self._device.id}-{self._device_type}" - - @property - def unique_id(self): - """Return a unique ID.""" - return self._unique_id + self._attr_unique_id = f"{self._device.id}-{self._device_type}" class SirenSwitch(BaseRingSwitch): """Creates a switch to turn the ring cameras siren on and off.""" _attr_translation_key = "siren" + _attr_icon = SIREN_ICON def __init__(self, config_entry_id, device): """Initialize the switch for a device with a siren.""" super().__init__(config_entry_id, device, "siren") self._no_updates_until = dt_util.utcnow() - self._siren_on = device.siren > 0 + self._attr_is_on = device.siren > 0 @callback def _update_callback(self): @@ -75,7 +71,7 @@ def _update_callback(self): if self._no_updates_until > dt_util.utcnow(): return - self._siren_on = self._device.siren > 0 + self._attr_is_on = self._device.siren > 0 self.async_write_ha_state() def _set_switch(self, new_state): @@ -86,15 +82,10 @@ def _set_switch(self, new_state): _LOGGER.error("Time out setting %s siren to %s", self.entity_id, new_state) return - self._siren_on = new_state > 0 + self._attr_is_on = new_state > 0 self._no_updates_until = dt_util.utcnow() + SKIP_UPDATES_DELAY self.schedule_update_ha_state() - @property - def is_on(self): - """If the switch is currently on or off.""" - return self._siren_on - def turn_on(self, **kwargs: Any) -> None: """Turn the siren on for 30 seconds.""" self._set_switch(1) @@ -102,8 +93,3 @@ def turn_on(self, **kwargs: Any) -> None: def turn_off(self, **kwargs: Any) -> None: """Turn the siren off.""" self._set_switch(0) - - @property - def icon(self): - """Return the icon.""" - return SIREN_ICON diff --git a/homeassistant/components/risco/entity.py b/homeassistant/components/risco/entity.py index 7f8e3be698b53f..f8869d75d4b30b 100644 --- a/homeassistant/components/risco/entity.py +++ b/homeassistant/components/risco/entity.py @@ -35,6 +35,7 @@ def _refresh_from_coordinator(self) -> None: self._get_data_from_coordinator() self.async_write_ha_state() + # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: """When entity is added to hass.""" self.async_on_remove( diff --git a/homeassistant/components/risco/sensor.py b/homeassistant/components/risco/sensor.py index bb416b8c55098e..b196723afbe0ea 100644 --- a/homeassistant/components/risco/sensor.py +++ b/homeassistant/components/risco/sensor.py @@ -86,6 +86,7 @@ def __init__( self._attr_name = f"Risco {self.coordinator.risco.site_name} {name} Events" self._attr_device_class = SensorDeviceClass.TIMESTAMP + # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: """When entity is added to hass.""" self._entity_registry = er.async_get(self.hass) diff --git a/homeassistant/components/roborock/binary_sensor.py b/homeassistant/components/roborock/binary_sensor.py new file mode 100644 index 00000000000000..320b0fc7c6de8d --- /dev/null +++ b/homeassistant/components/roborock/binary_sensor.py @@ -0,0 +1,118 @@ +"""Support for Roborock sensors.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from roborock.roborock_typing import DeviceProp + +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 AddEntitiesCallback +from homeassistant.util import slugify + +from .const import DOMAIN +from .coordinator import RoborockDataUpdateCoordinator +from .device import RoborockCoordinatedEntity + + +@dataclass +class RoborockBinarySensorDescriptionMixin: + """A class that describes binary sensor entities.""" + + value_fn: Callable[[DeviceProp], bool] + + +@dataclass +class RoborockBinarySensorDescription( + BinarySensorEntityDescription, RoborockBinarySensorDescriptionMixin +): + """A class that describes Roborock binary sensors.""" + + +BINARY_SENSOR_DESCRIPTIONS = [ + RoborockBinarySensorDescription( + key="dry_status", + translation_key="mop_drying_status", + icon="mdi:heat-wave", + device_class=BinarySensorDeviceClass.RUNNING, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.status.dry_status, + ), + RoborockBinarySensorDescription( + key="water_box_carriage_status", + translation_key="mop_attached", + icon="mdi:square-rounded", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.status.water_box_carriage_status, + ), + RoborockBinarySensorDescription( + key="water_box_status", + translation_key="water_box_attached", + icon="mdi:water", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.status.water_box_status, + ), + RoborockBinarySensorDescription( + key="water_shortage", + translation_key="water_shortage", + icon="mdi:water", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.status.water_shortage_status, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Roborock vacuum binary sensors.""" + coordinators: dict[str, RoborockDataUpdateCoordinator] = hass.data[DOMAIN][ + config_entry.entry_id + ] + async_add_entities( + RoborockBinarySensorEntity( + f"{description.key}_{slugify(device_id)}", + coordinator, + description, + ) + for device_id, coordinator in coordinators.items() + for description in BINARY_SENSOR_DESCRIPTIONS + if description.value_fn(coordinator.roborock_device_info.props) is not None + ) + + +class RoborockBinarySensorEntity(RoborockCoordinatedEntity, BinarySensorEntity): + """Representation of a Roborock binary sensor.""" + + entity_description: RoborockBinarySensorDescription + + def __init__( + self, + unique_id: str, + coordinator: RoborockDataUpdateCoordinator, + description: RoborockBinarySensorDescription, + ) -> None: + """Initialize the entity.""" + super().__init__(unique_id, coordinator) + self.entity_description = description + + @property + def is_on(self) -> bool: + """Return the value reported by the sensor.""" + return bool( + self.entity_description.value_fn( + self.coordinator.roborock_device_info.props + ) + ) diff --git a/homeassistant/components/roborock/const.py b/homeassistant/components/roborock/const.py index 2fc59134d140f8..36078e53b3e9ef 100644 --- a/homeassistant/components/roborock/const.py +++ b/homeassistant/components/roborock/const.py @@ -13,4 +13,5 @@ Platform.SWITCH, Platform.TIME, Platform.NUMBER, + Platform.BINARY_SENSOR, ] diff --git a/homeassistant/components/roborock/device.py b/homeassistant/components/roborock/device.py index 27f25208a4e9a8..2b005ecade6911 100644 --- a/homeassistant/components/roborock/device.py +++ b/homeassistant/components/roborock/device.py @@ -40,7 +40,7 @@ def get_cache(self, attribute: CacheableAttribute) -> AttributeCache: async def send( self, - command: RoborockCommand, + command: RoborockCommand | str, params: dict[str, Any] | list[Any] | int | None = None, ) -> dict: """Send a command to a vacuum cleaner.""" @@ -48,7 +48,7 @@ async def send( response = await self._api.send_command(command, params) except RoborockException as err: raise HomeAssistantError( - f"Error while calling {command.name} with {params}" + f"Error while calling {command.name if isinstance(command, RoborockCommand) else command} with {params}" ) from err return response diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index 01548a6334c636..dfd5a9ee1c7245 100644 --- a/homeassistant/components/roborock/manifest.json +++ b/homeassistant/components/roborock/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/roborock", "iot_class": "local_polling", "loggers": ["roborock"], - "requirements": ["python-roborock==0.32.3"] + "requirements": ["python-roborock==0.34.1"] } diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py index 2d76aac33d3b34..5cf71bb12f4663 100644 --- a/homeassistant/components/roborock/select.py +++ b/homeassistant/components/roborock/select.py @@ -7,6 +7,7 @@ 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.entity_platform import AddEntitiesCallback from homeassistant.util import slugify @@ -43,6 +44,7 @@ class RoborockSelectDescription( translation_key="mop_intensity", api_command=RoborockCommand.SET_WATER_BOX_CUSTOM_MODE, value_fn=lambda data: data.water_box_mode.name, + entity_category=EntityCategory.CONFIG, options_lambda=lambda data: data.water_box_mode.keys() if data.water_box_mode else None, @@ -53,6 +55,7 @@ class RoborockSelectDescription( translation_key="mop_mode", api_command=RoborockCommand.SET_MOP_MODE, value_fn=lambda data: data.mop_mode.name, + entity_category=EntityCategory.CONFIG, options_lambda=lambda data: data.mop_mode.keys() if data.mop_mode else None, parameter_lambda=lambda key, status: [status.mop_mode.as_dict().get(key)], ), diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py index 0629839f01bba7..8a18c281d593dc 100644 --- a/homeassistant/components/roborock/sensor.py +++ b/homeassistant/components/roborock/sensor.py @@ -4,7 +4,12 @@ from collections.abc import Callable from dataclasses import dataclass -from roborock.containers import RoborockErrorCode, RoborockStateCode +from roborock.containers import ( + RoborockDockErrorCode, + RoborockDockTypeCode, + RoborockErrorCode, + RoborockStateCode, +) from roborock.roborock_typing import DeviceProp from homeassistant.components.sensor import ( @@ -86,6 +91,7 @@ class RoborockSensorDescription( translation_key="cleaning_time", device_class=SensorDeviceClass.DURATION, value_fn=lambda data: data.status.clean_time, + entity_category=EntityCategory.DIAGNOSTIC, ), RoborockSensorDescription( native_unit_of_measurement=UnitOfTime.SECONDS, @@ -94,6 +100,7 @@ class RoborockSensorDescription( icon="mdi:history", device_class=SensorDeviceClass.DURATION, value_fn=lambda data: data.clean_summary.clean_time, + entity_category=EntityCategory.DIAGNOSTIC, ), RoborockSensorDescription( key="status", @@ -109,6 +116,7 @@ class RoborockSensorDescription( icon="mdi:texture-box", translation_key="cleaning_area", value_fn=lambda data: data.status.square_meter_clean_area, + entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=AREA_SQUARE_METERS, ), RoborockSensorDescription( @@ -116,6 +124,7 @@ class RoborockSensorDescription( icon="mdi:texture-box", translation_key="total_cleaning_area", value_fn=lambda data: data.clean_summary.square_meter_clean_area, + entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=AREA_SQUARE_METERS, ), RoborockSensorDescription( @@ -134,6 +143,51 @@ class RoborockSensorDescription( native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, ), + RoborockSensorDescription( + key="last_clean_start", + translation_key="last_clean_start", + icon="mdi:clock-time-twelve", + value_fn=lambda data: data.last_clean_record.begin_datetime, + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.TIMESTAMP, + ), + RoborockSensorDescription( + key="last_clean_end", + translation_key="last_clean_end", + icon="mdi:clock-time-twelve", + value_fn=lambda data: data.last_clean_record.end_datetime, + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.TIMESTAMP, + ), + # Only available on some newer models + RoborockSensorDescription( + key="clean_percent", + icon="mdi:progress-check", + translation_key="clean_percent", + value_fn=lambda data: data.status.clean_percent, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=PERCENTAGE, + ), + # Only available with more than just the basic dock + RoborockSensorDescription( + key="dock_error", + icon="mdi:garage-open", + translation_key="dock_error", + value_fn=lambda data: data.status.dock_error_status.name + if data.status.dock_type != RoborockDockTypeCode.no_dock + else None, + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.ENUM, + options=RoborockDockErrorCode.keys(), + ), + RoborockSensorDescription( + key="mop_clean_remaining", + native_unit_of_measurement=UnitOfTime.SECONDS, + device_class=SensorDeviceClass.DURATION, + value_fn=lambda data: data.status.rdt, + translation_key="mop_drying_remaining_time", + entity_category=EntityCategory.DIAGNOSTIC, + ), ] diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index 5ca2292f804186..c46eb81415142e 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -27,6 +27,20 @@ } }, "entity": { + "binary_sensor": { + "mop_attached": { + "name": "Mop attached" + }, + "mop_drying_status": { + "name": "Mop drying" + }, + "water_box_attached": { + "name": "Water box attached" + }, + "water_shortage": { + "name": "Water shortage" + } + }, "number": { "volume": { "name": "Volume" @@ -39,9 +53,32 @@ "cleaning_time": { "name": "Cleaning time" }, + "clean_percent": { + "name": "Cleaning progress" + }, + "dock_error": { + "name": "Dock error", + "state": { + "ok": "Ok", + "duct_blockage": "Duct blockage", + "water_empty": "Water empty", + "waste_water_tank_full": "Waste water tank full", + "dirty_tank_latch_open": "Dirty tank latch open", + "no_dustbin": "No dustbin" + } + }, "main_brush_time_left": { "name": "Main brush time left" }, + "mop_drying_remaining_time": { + "name": "Mop drying remaining time" + }, + "last_clean_start": { + "name": "Last clean begin" + }, + "last_clean_end": { + "name": "Last clean end" + }, "side_brush_time_left": { "name": "Side brush time left" }, @@ -164,10 +201,10 @@ "dnd_end_time": { "name": "Do not disturb end" }, - "off_peak_start_time": { + "off_peak_start": { "name": "Off-peak start" }, - "off_peak_end_time": { + "off_peak_end": { "name": "Off-peak end" } }, diff --git a/homeassistant/components/roku/media_player.py b/homeassistant/components/roku/media_player.py index 05f782b37c452e..62a1a1814599d6 100644 --- a/homeassistant/components/roku/media_player.py +++ b/homeassistant/components/roku/media_player.py @@ -122,6 +122,14 @@ class RokuMediaPlayer(RokuEntity, MediaPlayerEntity): | MediaPlayerEntityFeature.BROWSE_MEDIA ) + def __init__(self, coordinator: RokuDataUpdateCoordinator) -> None: + """Initialize the Roku device.""" + super().__init__(coordinator=coordinator) + if coordinator.data.info.device_type == "tv": + self._attr_device_class = MediaPlayerDeviceClass.TV + else: + self._attr_device_class = MediaPlayerDeviceClass.RECEIVER + def _media_playback_trackable(self) -> bool: """Detect if we have enough media data to track playback.""" if self.coordinator.data.media is None or self.coordinator.data.media.live: @@ -129,14 +137,6 @@ def _media_playback_trackable(self) -> bool: return self.coordinator.data.media.duration > 0 - @property - def device_class(self) -> MediaPlayerDeviceClass: - """Return the class of this device.""" - if self.coordinator.data.info.device_type == "tv": - return MediaPlayerDeviceClass.TV - - return MediaPlayerDeviceClass.RECEIVER - @property def state(self) -> MediaPlayerState | None: """Return the state of the device.""" diff --git a/homeassistant/components/roomba/binary_sensor.py b/homeassistant/components/roomba/binary_sensor.py index f480839388ca46..cd37e089c9f69a 100644 --- a/homeassistant/components/roomba/binary_sensor.py +++ b/homeassistant/components/roomba/binary_sensor.py @@ -27,7 +27,7 @@ async def async_setup_entry( class RoombaBinStatus(IRobotEntity, BinarySensorEntity): """Class to hold Roomba Sensor basic info.""" - ICON = "mdi:delete-variant" + _attr_icon = "mdi:delete-variant" _attr_translation_key = "bin_full" @property @@ -35,11 +35,6 @@ def unique_id(self): """Return the ID of this sensor.""" return f"bin_{self._blid}" - @property - def icon(self): - """Return the icon of this sensor.""" - return self.ICON - @property def is_on(self): """Return the state of the sensor.""" diff --git a/homeassistant/components/roomba/braava.py b/homeassistant/components/roomba/braava.py index ea08829cba6ece..db517a065ead65 100644 --- a/homeassistant/components/roomba/braava.py +++ b/homeassistant/components/roomba/braava.py @@ -29,6 +29,8 @@ class BraavaJet(IRobotVacuum): """Braava Jet.""" + _attr_supported_features = SUPPORT_BRAAVA + def __init__(self, roomba, blid): """Initialize the Roomba handler.""" super().__init__(roomba, blid) @@ -38,12 +40,7 @@ def __init__(self, roomba, blid): for behavior in BRAAVA_MOP_BEHAVIORS: for spray in BRAAVA_SPRAY_AMOUNT: speed_list.append(f"{behavior}-{spray}") - self._speed_list = speed_list - - @property - def supported_features(self): - """Flag vacuum cleaner robot features that are supported.""" - return SUPPORT_BRAAVA + self._attr_fan_speed_list = speed_list @property def fan_speed(self): @@ -62,11 +59,6 @@ def fan_speed(self): pad_wetness_value = pad_wetness.get("disposable") return f"{behavior}-{pad_wetness_value}" - @property - def fan_speed_list(self): - """Get the list of available fan speed steps of the vacuum cleaner.""" - return self._speed_list - async def async_set_fan_speed(self, fan_speed, **kwargs): """Set fan speed.""" try: diff --git a/homeassistant/components/roomba/irobot_base.py b/homeassistant/components/roomba/irobot_base.py index 8b909392250494..a48b363860836a 100644 --- a/homeassistant/components/roomba/irobot_base.py +++ b/homeassistant/components/roomba/irobot_base.py @@ -138,17 +138,14 @@ class IRobotVacuum(IRobotEntity, StateVacuumEntity): """Base class for iRobot robots.""" _attr_name = None + _attr_supported_features = SUPPORT_IROBOT + _attr_available = True # Always available, otherwise setup will fail def __init__(self, roomba, blid): """Initialize the iRobot handler.""" super().__init__(roomba, blid) self._cap_position = self.vacuum_state.get("cap", {}).get("pose") == 1 - @property - def supported_features(self): - """Flag vacuum cleaner robot features that are supported.""" - return SUPPORT_IROBOT - @property def battery_level(self): """Return the battery level of the vacuum cleaner.""" @@ -159,11 +156,6 @@ def state(self): """Return the state of the vacuum cleaner.""" return self._robot_state - @property - def available(self) -> bool: - """Return True if entity is available.""" - return True # Always available, otherwise setup will fail - @property def extra_state_attributes(self): """Return the state attributes of the device.""" diff --git a/homeassistant/components/roomba/roomba.py b/homeassistant/components/roomba/roomba.py index 7cac9a3ba52b92..2c50508a637e76 100644 --- a/homeassistant/components/roomba/roomba.py +++ b/homeassistant/components/roomba/roomba.py @@ -42,10 +42,8 @@ def extra_state_attributes(self): class RoombaVacuumCarpetBoost(RoombaVacuum): """Roomba robot with carpet boost.""" - @property - def supported_features(self): - """Flag vacuum cleaner robot features that are supported.""" - return SUPPORT_ROOMBA_CARPET_BOOST + _attr_fan_speed_list = FAN_SPEEDS + _attr_supported_features = SUPPORT_ROOMBA_CARPET_BOOST @property def fan_speed(self): @@ -62,11 +60,6 @@ def fan_speed(self): fan_speed = FAN_SPEED_ECO return fan_speed - @property - def fan_speed_list(self): - """Get the list of available fan speed steps of the vacuum cleaner.""" - return FAN_SPEEDS - async def async_set_fan_speed(self, fan_speed, **kwargs): """Set fan speed.""" if fan_speed.capitalize() in FAN_SPEEDS: diff --git a/homeassistant/components/ruckus_unleashed/__init__.py b/homeassistant/components/ruckus_unleashed/__init__.py index e71555598cb2be..63521a622cda42 100644 --- a/homeassistant/components/ruckus_unleashed/__init__.py +++ b/homeassistant/components/ruckus_unleashed/__init__.py @@ -2,7 +2,7 @@ import logging from aioruckus import AjaxSession -from aioruckus.exceptions import AuthenticationError +from aioruckus.exceptions import AuthenticationError, SchemaError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME @@ -31,16 +31,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Ruckus Unleashed from a config entry.""" + ruckus = AjaxSession.async_create( + entry.data[CONF_HOST], + entry.data[CONF_USERNAME], + entry.data[CONF_PASSWORD], + ) try: - ruckus = AjaxSession.async_create( - entry.data[CONF_HOST], - entry.data[CONF_USERNAME], - entry.data[CONF_PASSWORD], - ) await ruckus.login() - except (ConnectionRefusedError, ConnectionError) as conerr: + except (ConnectionError, SchemaError) as conerr: + await ruckus.close() raise ConfigEntryNotReady from conerr except AuthenticationError as autherr: + await ruckus.close() raise ConfigEntryAuthFailed from autherr coordinator = RuckusUnleashedDataUpdateCoordinator(hass, ruckus=ruckus) @@ -84,7 +86,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if unload_ok: for listener in hass.data[DOMAIN][entry.entry_id][UNDO_UPDATE_LISTENERS]: listener() - await hass.data[DOMAIN][entry.entry_id][COORDINATOR].ruckus.close() + await hass.data[DOMAIN][entry.entry_id][COORDINATOR].ruckus.close() hass.data[DOMAIN].pop(entry.entry_id) return unload_ok diff --git a/homeassistant/components/ruckus_unleashed/config_flow.py b/homeassistant/components/ruckus_unleashed/config_flow.py index 155eb68f5933d5..c11e9cbe89f1f8 100644 --- a/homeassistant/components/ruckus_unleashed/config_flow.py +++ b/homeassistant/components/ruckus_unleashed/config_flow.py @@ -1,9 +1,10 @@ """Config flow for Ruckus Unleashed integration.""" from collections.abc import Mapping +import logging from typing import Any from aioruckus import AjaxSession, SystemStat -from aioruckus.exceptions import AuthenticationError +from aioruckus.exceptions import AuthenticationError, SchemaError import voluptuous as vol from homeassistant import config_entries, core, exceptions @@ -19,6 +20,8 @@ KEY_SYS_TITLE, ) +_LOGGER = logging.getLogger(__package__) + DATA_SCHEMA = vol.Schema( { vol.Required(CONF_HOST): str, @@ -38,26 +41,29 @@ async def validate_input(hass: core.HomeAssistant, data): async with AjaxSession.async_create( data[CONF_HOST], data[CONF_USERNAME], data[CONF_PASSWORD] ) as ruckus: - system_info = await ruckus.api.get_system_info( - SystemStat.SYSINFO, - ) - mesh_name = (await ruckus.api.get_mesh_info())[API_MESH_NAME] - zd_serial = system_info[API_SYS_SYSINFO][API_SYS_SYSINFO_SERIAL] - return { - KEY_SYS_TITLE: mesh_name, - KEY_SYS_SERIAL: zd_serial, - } + mesh_info = await ruckus.api.get_mesh_info() + system_info = await ruckus.api.get_system_info(SystemStat.SYSINFO) except AuthenticationError as autherr: raise InvalidAuth from autherr - except (ConnectionRefusedError, ConnectionError, KeyError) as connerr: + except (ConnectionError, SchemaError) as connerr: raise CannotConnect from connerr + mesh_name = mesh_info[API_MESH_NAME] + zd_serial = system_info[API_SYS_SYSINFO][API_SYS_SYSINFO_SERIAL] + + return { + KEY_SYS_TITLE: mesh_name, + KEY_SYS_SERIAL: zd_serial, + } + class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for Ruckus Unleashed.""" VERSION = 1 + _reauth_entry: config_entries.ConfigEntry | None = None + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -70,30 +76,40 @@ async def async_step_user( errors["base"] = "cannot_connect" except InvalidAuth: errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" else: - await self.async_set_unique_id(info[KEY_SYS_SERIAL]) - self._abort_if_unique_id_configured() - return self.async_create_entry( - title=info[KEY_SYS_TITLE], data=user_input - ) - + if self._reauth_entry is None: + await self.async_set_unique_id(info[KEY_SYS_SERIAL]) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=info[KEY_SYS_TITLE], data=user_input + ) + if info[KEY_SYS_SERIAL] == self._reauth_entry.unique_id: + self.hass.config_entries.async_update_entry( + self._reauth_entry, data=user_input + ) + self.hass.async_create_task( + self.hass.config_entries.async_reload( + self._reauth_entry.entry_id + ) + ) + return self.async_abort(reason="reauth_successful") + errors["base"] = "invalid_host" + + data_schema = self.add_suggested_values_to_schema( + DATA_SCHEMA, self._reauth_entry.data if self._reauth_entry else {} + ) 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]) -> FlowResult: """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 - ) -> FlowResult: - """Dialog that informs the user that reauth is required.""" - if user_input is None: - return self.async_show_form( - step_id="reauth_confirm", - data_schema=DATA_SCHEMA, - ) + self._reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) return await self.async_step_user() diff --git a/homeassistant/components/ruckus_unleashed/coordinator.py b/homeassistant/components/ruckus_unleashed/coordinator.py index 29df676cb76bea..7c11aac7f688a8 100644 --- a/homeassistant/components/ruckus_unleashed/coordinator.py +++ b/homeassistant/components/ruckus_unleashed/coordinator.py @@ -3,9 +3,10 @@ import logging from aioruckus import AjaxSession -from aioruckus.exceptions import AuthenticationError +from aioruckus.exceptions import AuthenticationError, SchemaError from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import API_CLIENT_MAC, DOMAIN, KEY_SYS_CLIENTS, SCAN_INTERVAL @@ -40,6 +41,6 @@ async def _async_update_data(self) -> dict: try: return {KEY_SYS_CLIENTS: await self._fetch_clients()} except AuthenticationError as autherror: - raise UpdateFailed(autherror) from autherror - except (ConnectionRefusedError, ConnectionError) as conerr: + raise ConfigEntryAuthFailed(autherror) from autherror + except (ConnectionError, SchemaError) as conerr: raise UpdateFailed(conerr) from conerr diff --git a/homeassistant/components/ruckus_unleashed/device_tracker.py b/homeassistant/components/ruckus_unleashed/device_tracker.py index 0e0d2f103c45bc..df5027ebaa8df2 100644 --- a/homeassistant/components/ruckus_unleashed/device_tracker.py +++ b/homeassistant/components/ruckus_unleashed/device_tracker.py @@ -103,20 +103,16 @@ def mac_address(self) -> str: @property def name(self) -> str: """Return the name.""" - return ( - self._name - if not self.is_connected - else self.coordinator.data[KEY_SYS_CLIENTS][self._mac][API_CLIENT_HOSTNAME] - ) + if not self.is_connected: + return self._name + return self.coordinator.data[KEY_SYS_CLIENTS][self._mac][API_CLIENT_HOSTNAME] @property - def ip_address(self) -> str: + def ip_address(self) -> str | None: """Return the ip address.""" - return ( - self.coordinator.data[KEY_SYS_CLIENTS][self._mac][API_CLIENT_IP] - if self.is_connected - else None - ) + if not self.is_connected: + return None + return self.coordinator.data[KEY_SYS_CLIENTS][self._mac][API_CLIENT_IP] @property def is_connected(self) -> bool: diff --git a/homeassistant/components/ruckus_unleashed/manifest.json b/homeassistant/components/ruckus_unleashed/manifest.json index 8ff69fb1aa93dd..edaf0aa95d269b 100644 --- a/homeassistant/components/ruckus_unleashed/manifest.json +++ b/homeassistant/components/ruckus_unleashed/manifest.json @@ -1,11 +1,11 @@ { "domain": "ruckus_unleashed", "name": "Ruckus Unleashed", - "codeowners": ["@gabe565", "@lanrat"], + "codeowners": ["@lanrat", "@ms264556", "@gabe565"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/ruckus_unleashed", "integration_type": "hub", "iot_class": "local_polling", "loggers": ["aioruckus", "xmltodict"], - "requirements": ["aioruckus==0.31", "xmltodict==0.13.0"] + "requirements": ["aioruckus==0.34"] } diff --git a/homeassistant/components/ruckus_unleashed/strings.json b/homeassistant/components/ruckus_unleashed/strings.json index d6e3212b4eab64..769cde67d7aa8d 100644 --- a/homeassistant/components/ruckus_unleashed/strings.json +++ b/homeassistant/components/ruckus_unleashed/strings.json @@ -12,10 +12,12 @@ "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_host": "[%key:common::config_flow::error::invalid_host%]", "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } } } diff --git a/homeassistant/components/saj/sensor.py b/homeassistant/components/saj/sensor.py index 12a5ae99570e6c..866279af973811 100644 --- a/homeassistant/components/saj/sensor.py +++ b/homeassistant/components/saj/sensor.py @@ -181,7 +181,12 @@ class SAJsensor(SensorEntity): _attr_should_poll = False - def __init__(self, serialnumber, pysaj_sensor, inverter_name=None): + def __init__( + self, + serialnumber: str | None, + pysaj_sensor: pysaj.Sensor, + inverter_name: str | None = None, + ) -> None: """Initialize the SAJ sensor.""" self._sensor = pysaj_sensor self._inverter_name = inverter_name @@ -193,38 +198,28 @@ def __init__(self, serialnumber, pysaj_sensor, inverter_name=None): if pysaj_sensor.name == "total_yield": self._attr_state_class = SensorStateClass.TOTAL_INCREASING - @property - def name(self) -> str: - """Return the name of the sensor.""" + self._attr_unique_id = f"{serialnumber}_{pysaj_sensor.name}" + native_uom = SAJ_UNIT_MAPPINGS[pysaj_sensor.unit] + self._attr_native_unit_of_measurement = native_uom if self._inverter_name: - return f"saj_{self._inverter_name}_{self._sensor.name}" - - return f"saj_{self._sensor.name}" + self._attr_name = f"saj_{self._inverter_name}_{pysaj_sensor.name}" + else: + self._attr_name = f"saj_{pysaj_sensor.name}" + if native_uom == UnitOfPower.WATT: + self._attr_device_class = SensorDeviceClass.POWER + if native_uom == UnitOfEnergy.KILO_WATT_HOUR: + self._attr_device_class = SensorDeviceClass.ENERGY + if native_uom in ( + UnitOfTemperature.CELSIUS, + UnitOfTemperature.FAHRENHEIT, + ): + self._attr_device_class = SensorDeviceClass.TEMPERATURE @property def native_value(self): """Return the state of the sensor.""" return self._state - @property - def native_unit_of_measurement(self) -> str | None: - """Return the unit the value is expressed in.""" - return SAJ_UNIT_MAPPINGS[self._sensor.unit] - - @property - def device_class(self) -> SensorDeviceClass | None: - """Return the device class the sensor belongs to.""" - if self.native_unit_of_measurement == UnitOfPower.WATT: - return SensorDeviceClass.POWER - if self.native_unit_of_measurement == UnitOfEnergy.KILO_WATT_HOUR: - return SensorDeviceClass.ENERGY - if self.native_unit_of_measurement in ( - UnitOfTemperature.CELSIUS, - UnitOfTemperature.FAHRENHEIT, - ): - return SensorDeviceClass.TEMPERATURE - return None - @property def per_day_basis(self) -> bool: """Return if the sensors value is on daily basis or not.""" @@ -255,8 +250,3 @@ def async_update_values(self, unknown_state=False): if update: self.async_write_ha_state() - - @property - def unique_id(self) -> str: - """Return a unique identifier for this sensor.""" - return f"{self._serialnumber}_{self._sensor.name}" diff --git a/homeassistant/components/samsungtv/entity.py b/homeassistant/components/samsungtv/entity.py index e0ecbaac024b65..2b6373efc24003 100644 --- a/homeassistant/components/samsungtv/entity.py +++ b/homeassistant/components/samsungtv/entity.py @@ -21,7 +21,8 @@ def __init__(self, *, bridge: SamsungTVBridge, config_entry: ConfigEntry) -> Non self._bridge = bridge self._mac = config_entry.data.get(CONF_MAC) self._attr_name = config_entry.data.get(CONF_NAME) - self._attr_unique_id = config_entry.unique_id + # 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( # Instead of setting the device name to the entity name, samsungtv # should be updated to set has_entity_name = True diff --git a/homeassistant/components/samsungtv/manifest.json b/homeassistant/components/samsungtv/manifest.json index 9461eb86af6b9c..be75e3f4465fa0 100644 --- a/homeassistant/components/samsungtv/manifest.json +++ b/homeassistant/components/samsungtv/manifest.json @@ -39,7 +39,7 @@ "samsungctl[websocket]==0.7.1", "samsungtvws[async,encrypted]==2.6.0", "wakeonlan==2.1.0", - "async-upnp-client==0.35.0" + "async-upnp-client==0.35.1" ], "ssdp": [ { diff --git a/homeassistant/components/schedule/__init__.py b/homeassistant/components/schedule/__init__.py index 2e5fcc27715b6c..2f7831fedd463e 100644 --- a/homeassistant/components/schedule/__init__.py +++ b/homeassistant/components/schedule/__init__.py @@ -30,9 +30,6 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import async_track_point_in_utc_time -from homeassistant.helpers.integration_platform import ( - async_process_integration_platform_for_component, -) from homeassistant.helpers.service import async_register_admin_service from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType @@ -157,10 +154,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up an input select.""" component = EntityComponent[Schedule](LOGGER, DOMAIN, hass) - # Process integration platforms right away since - # we will create entities before firing EVENT_COMPONENT_LOADED - await async_process_integration_platform_for_component(hass, DOMAIN) - id_manager = IDManager() yaml_collection = YamlCollection(LOGGER, id_manager) @@ -240,6 +233,10 @@ async def _async_load_data(self) -> SerializedStorageCollection | None: class Schedule(CollectionEntity): """Schedule entity.""" + _entity_component_unrecorded_attributes = frozenset( + {ATTR_EDITABLE, ATTR_NEXT_EVENT} + ) + _attr_has_entity_name = True _attr_should_poll = False _attr_state: Literal["on", "off"] diff --git a/homeassistant/components/schedule/recorder.py b/homeassistant/components/schedule/recorder.py deleted file mode 100644 index b9911e0544bb22..00000000000000 --- a/homeassistant/components/schedule/recorder.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.const import ATTR_EDITABLE -from homeassistant.core import HomeAssistant, callback - -from .const import ATTR_NEXT_EVENT - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude configuration to be recorded in the database.""" - return { - ATTR_EDITABLE, - ATTR_NEXT_EVENT, - } diff --git a/homeassistant/components/schlage/__init__.py b/homeassistant/components/schlage/__init__.py index cf95e190e88d6c..feaa95864d56ee 100644 --- a/homeassistant/components/schlage/__init__.py +++ b/homeassistant/components/schlage/__init__.py @@ -11,7 +11,12 @@ from .const import DOMAIN, LOGGER from .coordinator import SchlageDataUpdateCoordinator -PLATFORMS: list[Platform] = [Platform.LOCK, Platform.SENSOR, Platform.SWITCH] +PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, + Platform.LOCK, + Platform.SENSOR, + Platform.SWITCH, +] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/schlage/binary_sensor.py b/homeassistant/components/schlage/binary_sensor.py new file mode 100644 index 00000000000000..749a961a53b922 --- /dev/null +++ b/homeassistant/components/schlage/binary_sensor.py @@ -0,0 +1,92 @@ +"""Platform for Schlage binary_sensor integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +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 AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import LockData, SchlageDataUpdateCoordinator +from .entity import SchlageEntity + + +@dataclass +class SchlageBinarySensorEntityDescriptionMixin: + """Mixin for required keys.""" + + # NOTE: This has to be a mixin because these are required keys. + # BinarySensorEntityDescription has attributes with default values, + # which means we can't inherit from it because you haven't have + # non-default arguments follow default arguments in an initializer. + + value_fn: Callable[[LockData], bool] + + +@dataclass +class SchlageBinarySensorEntityDescription( + BinarySensorEntityDescription, SchlageBinarySensorEntityDescriptionMixin +): + """Entity description for a Schlage binary_sensor.""" + + +_DESCRIPTIONS: tuple[SchlageBinarySensorEntityDescription] = ( + SchlageBinarySensorEntityDescription( + key="keypad_disabled", + translation_key="keypad_disabled", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.lock.keypad_disabled(data.logs), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up binary_sensors based on a config entry.""" + coordinator: SchlageDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + entities = [] + for device_id in coordinator.data.locks: + for description in _DESCRIPTIONS: + entities.append( + SchlageBinarySensor( + coordinator=coordinator, + description=description, + device_id=device_id, + ) + ) + async_add_entities(entities) + + +class SchlageBinarySensor(SchlageEntity, BinarySensorEntity): + """Schlage binary_sensor entity.""" + + entity_description: SchlageBinarySensorEntityDescription + + def __init__( + self, + coordinator: SchlageDataUpdateCoordinator, + description: SchlageBinarySensorEntityDescription, + device_id: str, + ) -> None: + """Initialize a SchlageBinarySensor.""" + super().__init__(coordinator, device_id) + self.entity_description = description + self._attr_unique_id = f"{device_id}_{self.entity_description.key}" + + @property + def is_on(self) -> bool | None: + """Return true if the binary_sensor is on.""" + return self.entity_description.value_fn(self._lock_data) diff --git a/homeassistant/components/schlage/manifest.json b/homeassistant/components/schlage/manifest.json index 25316004c58c9c..fb4ccc81deed26 100644 --- a/homeassistant/components/schlage/manifest.json +++ b/homeassistant/components/schlage/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/schlage", "iot_class": "cloud_polling", - "requirements": ["pyschlage==2023.8.1"] + "requirements": ["pyschlage==2023.9.0"] } diff --git a/homeassistant/components/schlage/strings.json b/homeassistant/components/schlage/strings.json index f3612bb96b8e49..076ed97e298d89 100644 --- a/homeassistant/components/schlage/strings.json +++ b/homeassistant/components/schlage/strings.json @@ -17,6 +17,11 @@ } }, "entity": { + "binary_sensor": { + "keypad_disabled": { + "name": "Keypad disabled" + } + }, "switch": { "beeper": { "name": "Keypress Beep" diff --git a/homeassistant/components/scrape/sensor.py b/homeassistant/components/scrape/sensor.py index 77131ccb22504e..bb8c233983d360 100644 --- a/homeassistant/components/scrape/sensor.py +++ b/homeassistant/components/scrape/sensor.py @@ -192,9 +192,7 @@ def _extract_value(self) -> Any: async def async_added_to_hass(self) -> None: """Ensure the data from the initial update is reflected in the state.""" - await ManualTriggerEntity.async_added_to_hass(self) - # https://github.com/python/mypy/issues/15097 - await CoordinatorEntity.async_added_to_hass(self) # type: ignore[arg-type] + await super().async_added_to_hass() self._async_update_from_rest_data() def _async_update_from_rest_data(self) -> None: diff --git a/homeassistant/components/screenlogic/__init__.py b/homeassistant/components/screenlogic/__init__.py index 3370c196c3c902..298e1c1ca0010f 100644 --- a/homeassistant/components/screenlogic/__init__.py +++ b/homeassistant/components/screenlogic/__init__.py @@ -1,27 +1,22 @@ """The Screenlogic integration.""" -from datetime import timedelta import logging from typing import Any from screenlogicpy import ScreenLogicError, ScreenLogicGateway -from screenlogicpy.const import ( - DATA as SL_DATA, - EQUIPMENT, - SL_GATEWAY_IP, - SL_GATEWAY_NAME, - SL_GATEWAY_PORT, -) +from screenlogicpy.const.data import SHARED_VALUES from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT, CONF_SCAN_INTERVAL, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers.debounce import Debouncer -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.helpers import entity_registry as er +from homeassistant.util import slugify -from .config_flow import async_discover_gateways_by_unique_id, name_for_mac -from .const import DEFAULT_SCAN_INTERVAL, DOMAIN +from .const import DOMAIN +from .coordinator import ScreenlogicDataUpdateCoordinator, async_get_connect_info +from .data import ENTITY_MIGRATIONS from .services import async_load_screenlogic_services, async_unload_screenlogic_services +from .util import generate_unique_id _LOGGER = logging.getLogger(__name__) @@ -44,12 +39,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Screenlogic from a config entry.""" + + await _async_migrate_entries(hass, entry) + gateway = ScreenLogicGateway() connect_info = await async_get_connect_info(hass, entry) try: await gateway.async_connect(**connect_info) + await gateway.async_update() except ScreenLogicError as ex: raise ConfigEntryNotReady(ex.msg) from ex @@ -88,83 +87,88 @@ async def async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None await hass.config_entries.async_reload(entry.entry_id) -async def async_get_connect_info( - hass: HomeAssistant, entry: ConfigEntry -) -> dict[str, str | int]: - """Construct connect_info from configuration entry and returns it to caller.""" - mac = entry.unique_id - # Attempt to rediscover gateway to follow IP changes - discovered_gateways = await async_discover_gateways_by_unique_id(hass) - if mac in discovered_gateways: - return discovered_gateways[mac] - - _LOGGER.warning("Gateway rediscovery failed") - # Static connection defined or fallback from discovery - return { - SL_GATEWAY_NAME: name_for_mac(mac), - SL_GATEWAY_IP: entry.data[CONF_IP_ADDRESS], - SL_GATEWAY_PORT: entry.data[CONF_PORT], - } - - -class ScreenlogicDataUpdateCoordinator(DataUpdateCoordinator[None]): - """Class to manage the data update for the Screenlogic component.""" - - def __init__( - self, - hass: HomeAssistant, - *, - config_entry: ConfigEntry, - gateway: ScreenLogicGateway, - ) -> None: - """Initialize the Screenlogic Data Update Coordinator.""" - self.config_entry = config_entry - self.gateway = gateway - - interval = timedelta( - seconds=config_entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) - ) - super().__init__( - hass, - _LOGGER, - name=DOMAIN, - update_interval=interval, - # Debounced option since the device takes - # a moment to reflect the knock-on changes - request_refresh_debouncer=Debouncer( - hass, _LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=False - ), +async def _async_migrate_entries( + hass: HomeAssistant, config_entry: ConfigEntry +) -> None: + """Migrate to new entity names.""" + entity_registry = er.async_get(hass) + + for entry in er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ): + source_mac, source_key = entry.unique_id.split("_", 1) + + source_index = None + if ( + len(key_parts := source_key.rsplit("_", 1)) == 2 + and key_parts[1].isdecimal() + ): + source_key, source_index = key_parts + + _LOGGER.debug( + "Checking migration status for '%s' against key '%s'", + entry.unique_id, + source_key, ) - @property - def gateway_data(self) -> dict[str | int, Any]: - """Return the gateway data.""" - return self.gateway.get_data() - - async def _async_update_configured_data(self) -> None: - """Update data sets based on equipment config.""" - equipment_flags = self.gateway.get_data()[SL_DATA.KEY_CONFIG]["equipment_flags"] - if not self.gateway.is_client: - await self.gateway.async_get_status() - if equipment_flags & EQUIPMENT.FLAG_INTELLICHEM: - await self.gateway.async_get_chemistry() - - await self.gateway.async_get_pumps() - if equipment_flags & EQUIPMENT.FLAG_CHLORINATOR: - await self.gateway.async_get_scg() - - async def _async_update_data(self) -> None: - """Fetch data from the Screenlogic gateway.""" - assert self.config_entry is not None - try: - if not self.gateway.is_connected: - connect_info = await async_get_connect_info( - self.hass, self.config_entry - ) - await self.gateway.async_connect(**connect_info) + if source_key not in ENTITY_MIGRATIONS: + continue - await self._async_update_configured_data() - except ScreenLogicError as ex: - if self.gateway.is_connected: - await self.gateway.async_disconnect() - raise UpdateFailed(ex.msg) from ex + _LOGGER.debug( + "Evaluating migration of '%s' from migration key '%s'", + entry.entity_id, + source_key, + ) + migrations = ENTITY_MIGRATIONS[source_key] + updates: dict[str, Any] = {} + new_key = migrations["new_key"] + if new_key in SHARED_VALUES: + if (device := migrations.get("device")) is None: + _LOGGER.debug( + "Shared key '%s' is missing required migration data 'device'", + new_key, + ) + continue + assert device is not None and ( + device != "pump" or (device == "pump" and source_index is not None) + ) + new_unique_id = ( + f"{source_mac}_{generate_unique_id(device, source_index, new_key)}" + ) + else: + new_unique_id = entry.unique_id.replace(source_key, new_key) + + if new_unique_id and new_unique_id != entry.unique_id: + if existing_entity_id := entity_registry.async_get_entity_id( + entry.domain, entry.platform, new_unique_id + ): + _LOGGER.debug( + "Cannot migrate '%s' to unique_id '%s', already exists for entity '%s'. Aborting", + entry.unique_id, + new_unique_id, + existing_entity_id, + ) + continue + updates["new_unique_id"] = new_unique_id + + if (old_name := migrations.get("old_name")) is not None: + assert old_name + new_name = migrations["new_name"] + if (s_old_name := slugify(old_name)) in entry.entity_id: + new_entity_id = entry.entity_id.replace(s_old_name, slugify(new_name)) + if new_entity_id and new_entity_id != entry.entity_id: + updates["new_entity_id"] = new_entity_id + + if entry.original_name and old_name in entry.original_name: + new_original_name = entry.original_name.replace(old_name, new_name) + if new_original_name and new_original_name != entry.original_name: + updates["original_name"] = new_original_name + + if updates: + _LOGGER.debug( + "Migrating entity '%s' unique_id from '%s' to '%s'", + entry.entity_id, + entry.unique_id, + new_unique_id, + ) + entity_registry.async_update_entity(entry.entity_id, **updates) diff --git a/homeassistant/components/screenlogic/binary_sensor.py b/homeassistant/components/screenlogic/binary_sensor.py index 305775844942a2..337d308d8d9437 100644 --- a/homeassistant/components/screenlogic/binary_sensor.py +++ b/homeassistant/components/screenlogic/binary_sensor.py @@ -1,28 +1,97 @@ """Support for a ScreenLogic Binary Sensor.""" -from screenlogicpy.const import CODE, DATA as SL_DATA, DEVICE_TYPE, EQUIPMENT, ON_OFF +from dataclasses import dataclass +import logging + +from screenlogicpy.const.common import DEVICE_TYPE, ON_OFF +from screenlogicpy.const.data import ATTR, DEVICE, GROUP, VALUE from homeassistant.components.binary_sensor import ( + DOMAIN, 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 AddEntitiesCallback -from . import ScreenlogicDataUpdateCoordinator -from .const import DOMAIN -from .entity import ScreenlogicEntity, ScreenLogicPushEntity +from .const import DOMAIN as SL_DOMAIN, ScreenLogicDataPath +from .coordinator import ScreenlogicDataUpdateCoordinator +from .data import ( + DEVICE_INCLUSION_RULES, + DEVICE_SUBSCRIPTION, + SupportedValueParameters, + build_base_entity_description, + iterate_expand_group_wildcard, + preprocess_supported_values, +) +from .entity import ( + ScreenlogicEntity, + ScreenLogicEntityDescription, + ScreenLogicPushEntity, + ScreenLogicPushEntityDescription, +) +from .util import cleanup_excluded_entity, generate_unique_id + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class SupportedBinarySensorValueParameters(SupportedValueParameters): + """Supported predefined data for a ScreenLogic binary sensor entity.""" + + device_class: BinarySensorDeviceClass | None = None + + +SUPPORTED_DATA: list[ + tuple[ScreenLogicDataPath, SupportedValueParameters] +] = preprocess_supported_values( + { + DEVICE.CONTROLLER: { + GROUP.SENSOR: { + VALUE.ACTIVE_ALERT: SupportedBinarySensorValueParameters(), + VALUE.CLEANER_DELAY: SupportedBinarySensorValueParameters(), + VALUE.FREEZE_MODE: SupportedBinarySensorValueParameters(), + VALUE.POOL_DELAY: SupportedBinarySensorValueParameters(), + VALUE.SPA_DELAY: SupportedBinarySensorValueParameters(), + }, + }, + DEVICE.PUMP: { + "*": { + VALUE.STATE: SupportedBinarySensorValueParameters(), + }, + }, + DEVICE.INTELLICHEM: { + GROUP.ALARM: { + VALUE.FLOW_ALARM: SupportedBinarySensorValueParameters(), + VALUE.ORP_HIGH_ALARM: SupportedBinarySensorValueParameters(), + VALUE.ORP_LOW_ALARM: SupportedBinarySensorValueParameters(), + VALUE.ORP_SUPPLY_ALARM: SupportedBinarySensorValueParameters(), + VALUE.PH_HIGH_ALARM: SupportedBinarySensorValueParameters(), + VALUE.PH_LOW_ALARM: SupportedBinarySensorValueParameters(), + VALUE.PH_SUPPLY_ALARM: SupportedBinarySensorValueParameters(), + VALUE.PROBE_FAULT_ALARM: SupportedBinarySensorValueParameters(), + }, + GROUP.ALERT: { + VALUE.ORP_LIMIT: SupportedBinarySensorValueParameters(), + VALUE.PH_LIMIT: SupportedBinarySensorValueParameters(), + VALUE.PH_LOCKOUT: SupportedBinarySensorValueParameters(), + }, + GROUP.WATER_BALANCE: { + VALUE.CORROSIVE: SupportedBinarySensorValueParameters(), + VALUE.SCALING: SupportedBinarySensorValueParameters(), + }, + }, + DEVICE.SCG: { + GROUP.SENSOR: { + VALUE.STATE: SupportedBinarySensorValueParameters(), + }, + }, + } +) SL_DEVICE_TYPE_TO_HA_DEVICE_CLASS = {DEVICE_TYPE.ALARM: BinarySensorDeviceClass.PROBLEM} -SUPPORTED_CONFIG_BINARY_SENSORS = ( - "freeze_mode", - "pool_delay", - "spa_delay", - "cleaner_delay", -) - async def async_setup_entry( hass: HomeAssistant, @@ -30,132 +99,92 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up entry.""" - entities: list[ScreenLogicBinarySensorEntity] = [] - coordinator: ScreenlogicDataUpdateCoordinator = hass.data[DOMAIN][ + entities: list[ScreenLogicBinarySensor] = [] + coordinator: ScreenlogicDataUpdateCoordinator = hass.data[SL_DOMAIN][ config_entry.entry_id ] - gateway_data = coordinator.gateway_data - config = gateway_data[SL_DATA.KEY_CONFIG] - - # Generic binary sensor - entities.append( - ScreenLogicStatusBinarySensor(coordinator, "chem_alarm", CODE.STATUS_CHANGED) - ) - - entities.extend( - [ - ScreenlogicConfigBinarySensor(coordinator, cfg_sensor, CODE.STATUS_CHANGED) - for cfg_sensor in config - if cfg_sensor in SUPPORTED_CONFIG_BINARY_SENSORS - ] - ) - - if config["equipment_flags"] & EQUIPMENT.FLAG_INTELLICHEM: - chemistry = gateway_data[SL_DATA.KEY_CHEMISTRY] - # IntelliChem alarm sensors - entities.extend( - [ - ScreenlogicChemistryAlarmBinarySensor( - coordinator, chem_alarm, CODE.CHEMISTRY_CHANGED + gateway = coordinator.gateway + data_path: ScreenLogicDataPath + value_params: SupportedBinarySensorValueParameters + for data_path, value_params in iterate_expand_group_wildcard( + gateway, SUPPORTED_DATA + ): + entity_key = generate_unique_id(*data_path) + + device = data_path[0] + + if not (DEVICE_INCLUSION_RULES.get(device) or value_params.included).test( + gateway, data_path + ): + cleanup_excluded_entity(coordinator, DOMAIN, entity_key) + continue + + try: + value_data = gateway.get_data(*data_path, strict=True) + except KeyError: + _LOGGER.debug("Failed to find %s", data_path) + continue + + entity_description_kwargs = { + **build_base_entity_description( + gateway, entity_key, data_path, value_data, value_params + ), + "device_class": SL_DEVICE_TYPE_TO_HA_DEVICE_CLASS.get( + value_data.get(ATTR.DEVICE_TYPE) + ), + } + + if ( + sub_code := ( + value_params.subscription_code or DEVICE_SUBSCRIPTION.get(device) + ) + ) is not None: + entities.append( + ScreenLogicPushBinarySensor( + coordinator, + ScreenLogicPushBinarySensorDescription( + subscription_code=sub_code, **entity_description_kwargs + ), ) - for chem_alarm in chemistry[SL_DATA.KEY_ALERTS] - if not chem_alarm.startswith("_") - ] - ) - - # Intellichem notification sensors - entities.extend( - [ - ScreenlogicChemistryNotificationBinarySensor( - coordinator, chem_notif, CODE.CHEMISTRY_CHANGED + ) + else: + entities.append( + ScreenLogicBinarySensor( + coordinator, + ScreenLogicBinarySensorDescription(**entity_description_kwargs), ) - for chem_notif in chemistry[SL_DATA.KEY_NOTIFICATIONS] - if not chem_notif.startswith("_") - ] - ) - - if config["equipment_flags"] & EQUIPMENT.FLAG_CHLORINATOR: - # SCG binary sensor - entities.append(ScreenlogicSCGBinarySensor(coordinator, "scg_status")) + ) async_add_entities(entities) -class ScreenLogicBinarySensorEntity(ScreenlogicEntity, BinarySensorEntity): - """Base class for all ScreenLogic binary sensor entities.""" +@dataclass +class ScreenLogicBinarySensorDescription( + BinarySensorEntityDescription, ScreenLogicEntityDescription +): + """A class that describes ScreenLogic binary sensor eneites.""" - _attr_has_entity_name = True - _attr_entity_category = EntityCategory.DIAGNOSTIC - @property - def name(self) -> str | None: - """Return the sensor name.""" - return self.sensor["name"] +class ScreenLogicBinarySensor(ScreenlogicEntity, BinarySensorEntity): + """Base class for all ScreenLogic binary sensor entities.""" - @property - def device_class(self) -> BinarySensorDeviceClass | None: - """Return the device class.""" - device_type = self.sensor.get("device_type") - return SL_DEVICE_TYPE_TO_HA_DEVICE_CLASS.get(device_type) + entity_description: ScreenLogicBinarySensorDescription + _attr_has_entity_name = True @property def is_on(self) -> bool: """Determine if the sensor is on.""" - return self.sensor["value"] == ON_OFF.ON - - @property - def sensor(self) -> dict: - """Shortcut to access the sensor data.""" - return self.gateway_data[SL_DATA.KEY_SENSORS][self._data_key] - - -class ScreenLogicStatusBinarySensor( - ScreenLogicBinarySensorEntity, ScreenLogicPushEntity -): - """Representation of a basic ScreenLogic sensor entity.""" + return self.entity_data[ATTR.VALUE] == ON_OFF.ON -class ScreenlogicChemistryAlarmBinarySensor( - ScreenLogicBinarySensorEntity, ScreenLogicPushEntity +@dataclass +class ScreenLogicPushBinarySensorDescription( + ScreenLogicBinarySensorDescription, ScreenLogicPushEntityDescription ): - """Representation of a ScreenLogic IntelliChem alarm binary sensor entity.""" - - @property - def sensor(self) -> dict: - """Shortcut to access the sensor data.""" - return self.gateway_data[SL_DATA.KEY_CHEMISTRY][SL_DATA.KEY_ALERTS][ - self._data_key - ] + """Describes a ScreenLogicPushBinarySensor.""" -class ScreenlogicChemistryNotificationBinarySensor( - ScreenLogicBinarySensorEntity, ScreenLogicPushEntity -): - """Representation of a ScreenLogic IntelliChem notification binary sensor entity.""" - - @property - def sensor(self) -> dict: - """Shortcut to access the sensor data.""" - return self.gateway_data[SL_DATA.KEY_CHEMISTRY][SL_DATA.KEY_NOTIFICATIONS][ - self._data_key - ] - - -class ScreenlogicSCGBinarySensor(ScreenLogicBinarySensorEntity): - """Representation of a ScreenLogic SCG binary sensor entity.""" - - @property - def sensor(self) -> dict: - """Shortcut to access the sensor data.""" - return self.gateway_data[SL_DATA.KEY_SCG][self._data_key] - - -class ScreenlogicConfigBinarySensor( - ScreenLogicBinarySensorEntity, ScreenLogicPushEntity -): - """Representation of a ScreenLogic config data binary sensor entity.""" +class ScreenLogicPushBinarySensor(ScreenLogicPushEntity, ScreenLogicBinarySensor): + """Representation of a basic ScreenLogic sensor entity.""" - @property - def sensor(self) -> dict: - """Shortcut to access the sensor data.""" - return self.gateway_data[SL_DATA.KEY_CONFIG][self._data_key] + entity_description: ScreenLogicPushBinarySensorDescription diff --git a/homeassistant/components/screenlogic/climate.py b/homeassistant/components/screenlogic/climate.py index cea546262aea2d..889c8617274421 100644 --- a/homeassistant/components/screenlogic/climate.py +++ b/homeassistant/components/screenlogic/climate.py @@ -1,12 +1,18 @@ """Support for a ScreenLogic heating device.""" +from dataclasses import dataclass import logging from typing import Any -from screenlogicpy.const import CODE, DATA as SL_DATA, EQUIPMENT, HEAT_MODE +from screenlogicpy.const.common import UNIT +from screenlogicpy.const.data import ATTR, DEVICE, VALUE +from screenlogicpy.const.msg import CODE +from screenlogicpy.device_const.heat import HEAT_MODE +from screenlogicpy.device_const.system import EQUIPMENT_FLAG from homeassistant.components.climate import ( ATTR_PRESET_MODE, ClimateEntity, + ClimateEntityDescription, ClimateEntityFeature, HVACAction, HVACMode, @@ -18,9 +24,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -from . import ScreenlogicDataUpdateCoordinator -from .const import DOMAIN -from .entity import ScreenLogicPushEntity +from .const import DOMAIN as SL_DOMAIN +from .coordinator import ScreenlogicDataUpdateCoordinator +from .entity import ScreenLogicPushEntity, ScreenLogicPushEntityDescription _LOGGER = logging.getLogger(__name__) @@ -41,81 +47,88 @@ async def async_setup_entry( ) -> None: """Set up entry.""" entities = [] - coordinator: ScreenlogicDataUpdateCoordinator = hass.data[DOMAIN][ + coordinator: ScreenlogicDataUpdateCoordinator = hass.data[SL_DOMAIN][ config_entry.entry_id ] - for body in coordinator.gateway_data[SL_DATA.KEY_BODIES]: - entities.append(ScreenLogicClimate(coordinator, body)) + gateway = coordinator.gateway + + for body_index, body_data in gateway.get_data(DEVICE.BODY).items(): + body_path = (DEVICE.BODY, body_index) + entities.append( + ScreenLogicClimate( + coordinator, + ScreenLogicClimateDescription( + subscription_code=CODE.STATUS_CHANGED, + data_path=body_path, + key=body_index, + name=body_data[VALUE.HEAT_STATE][ATTR.NAME], + ), + ) + ) async_add_entities(entities) +@dataclass +class ScreenLogicClimateDescription( + ClimateEntityDescription, ScreenLogicPushEntityDescription +): + """Describes a ScreenLogic climate entity.""" + + class ScreenLogicClimate(ScreenLogicPushEntity, ClimateEntity, RestoreEntity): """Represents a ScreenLogic climate entity.""" - _attr_has_entity_name = True - + entity_description: ScreenLogicClimateDescription _attr_hvac_modes = SUPPORTED_MODES _attr_supported_features = ( ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE ) - def __init__(self, coordinator, body): + def __init__(self, coordinator, entity_description) -> None: """Initialize a ScreenLogic climate entity.""" - super().__init__(coordinator, body, CODE.STATUS_CHANGED) + super().__init__(coordinator, entity_description) self._configured_heat_modes = [] # Is solar listed as available equipment? - if self.gateway_data["config"]["equipment_flags"] & EQUIPMENT.FLAG_SOLAR: + if EQUIPMENT_FLAG.SOLAR in self.gateway.equipment_flags: self._configured_heat_modes.extend( [HEAT_MODE.SOLAR, HEAT_MODE.SOLAR_PREFERRED] ) self._configured_heat_modes.append(HEAT_MODE.HEATER) - self._last_preset = None - - @property - def name(self) -> str: - """Name of the heater.""" - return self.body["heat_status"]["name"] - @property - def min_temp(self) -> float: - """Minimum allowed temperature.""" - return self.body["min_set_point"]["value"] - - @property - def max_temp(self) -> float: - """Maximum allowed temperature.""" - return self.body["max_set_point"]["value"] + self._attr_min_temp = self.entity_data[ATTR.MIN_SETPOINT] + self._attr_max_temp = self.entity_data[ATTR.MAX_SETPOINT] + self._last_preset = None @property def current_temperature(self) -> float: """Return water temperature.""" - return self.body["last_temperature"]["value"] + return self.entity_data[VALUE.LAST_TEMPERATURE][ATTR.VALUE] @property def target_temperature(self) -> float: """Target temperature.""" - return self.body["heat_set_point"]["value"] + return self.entity_data[VALUE.HEAT_SETPOINT][ATTR.VALUE] @property def temperature_unit(self) -> str: """Return the unit of measurement.""" - if self.config_data["is_celsius"]["value"] == 1: + if self.gateway.temperature_unit == UNIT.CELSIUS: return UnitOfTemperature.CELSIUS return UnitOfTemperature.FAHRENHEIT @property def hvac_mode(self) -> HVACMode: """Return the current hvac mode.""" - if self.body["heat_mode"]["value"] > 0: + if self.entity_data[VALUE.HEAT_MODE][ATTR.VALUE] > 0: return HVACMode.HEAT return HVACMode.OFF @property def hvac_action(self) -> HVACAction: """Return the current action of the heater.""" - if self.body["heat_status"]["value"] > 0: + if self.entity_data[VALUE.HEAT_STATE][ATTR.VALUE] > 0: return HVACAction.HEATING if self.hvac_mode == HVACMode.HEAT: return HVACAction.IDLE @@ -125,15 +138,13 @@ def hvac_action(self) -> HVACAction: def preset_mode(self) -> str: """Return current/last preset mode.""" if self.hvac_mode == HVACMode.OFF: - return HEAT_MODE.NAME_FOR_NUM[self._last_preset] - return HEAT_MODE.NAME_FOR_NUM[self.body["heat_mode"]["value"]] + return HEAT_MODE(self._last_preset).title + return HEAT_MODE(self.entity_data[VALUE.HEAT_MODE][ATTR.VALUE]).title @property def preset_modes(self) -> list[str]: """All available presets.""" - return [ - HEAT_MODE.NAME_FOR_NUM[mode_num] for mode_num in self._configured_heat_modes - ] + return [HEAT_MODE(mode_num).title for mode_num in self._configured_heat_modes] async def async_set_temperature(self, **kwargs: Any) -> None: """Change the setpoint of the heater.""" @@ -145,7 +156,7 @@ async def async_set_temperature(self, **kwargs: Any) -> None: ): raise HomeAssistantError( f"Failed to set_temperature {temperature} on body" - f" {self.body['body_type']['value']}" + f" {self.entity_data[ATTR.BODY_TYPE][ATTR.VALUE]}" ) _LOGGER.debug("Set temperature for body %s to %s", self._data_key, temperature) @@ -154,28 +165,33 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: if hvac_mode == HVACMode.OFF: mode = HEAT_MODE.OFF else: - mode = HEAT_MODE.NUM_FOR_NAME[self.preset_mode] + mode = HEAT_MODE.parse(self.preset_mode) - if not await self.gateway.async_set_heat_mode(int(self._data_key), int(mode)): + if not await self.gateway.async_set_heat_mode( + int(self._data_key), int(mode.value) + ): raise HomeAssistantError( - f"Failed to set_hvac_mode {mode} on body" - f" {self.body['body_type']['value']}" + f"Failed to set_hvac_mode {mode.name} on body" + f" {self.entity_data[ATTR.BODY_TYPE][ATTR.VALUE]}" ) - _LOGGER.debug("Set hvac_mode on body %s to %s", self._data_key, mode) + _LOGGER.debug("Set hvac_mode on body %s to %s", self._data_key, mode.name) async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode.""" - _LOGGER.debug("Setting last_preset to %s", HEAT_MODE.NUM_FOR_NAME[preset_mode]) - self._last_preset = mode = HEAT_MODE.NUM_FOR_NAME[preset_mode] + mode = HEAT_MODE.parse(preset_mode) + _LOGGER.debug("Setting last_preset to %s", mode.name) + self._last_preset = mode.value if self.hvac_mode == HVACMode.OFF: return - if not await self.gateway.async_set_heat_mode(int(self._data_key), int(mode)): + if not await self.gateway.async_set_heat_mode( + int(self._data_key), int(mode.value) + ): raise HomeAssistantError( - f"Failed to set_preset_mode {mode} on body" - f" {self.body['body_type']['value']}" + f"Failed to set_preset_mode {mode.name} on body" + f" {self.entity_data[ATTR.BODY_TYPE][ATTR.VALUE]}" ) - _LOGGER.debug("Set preset_mode on body %s to %s", self._data_key, mode) + _LOGGER.debug("Set preset_mode on body %s to %s", self._data_key, mode.name) async def async_added_to_hass(self) -> None: """Run when entity is about to be added.""" @@ -189,21 +205,16 @@ async def async_added_to_hass(self) -> None: prev_state is not None and prev_state.attributes.get(ATTR_PRESET_MODE) is not None ): + mode = HEAT_MODE.parse(prev_state.attributes.get(ATTR_PRESET_MODE)) _LOGGER.debug( "Startup setting last_preset to %s from prev_state", - HEAT_MODE.NUM_FOR_NAME[prev_state.attributes.get(ATTR_PRESET_MODE)], + mode.name, ) - self._last_preset = HEAT_MODE.NUM_FOR_NAME[ - prev_state.attributes.get(ATTR_PRESET_MODE) - ] + self._last_preset = mode.value else: + mode = HEAT_MODE.parse(self._configured_heat_modes[0]) _LOGGER.debug( "Startup setting last_preset to default (%s)", - self._configured_heat_modes[0], + mode.name, ) - self._last_preset = self._configured_heat_modes[0] - - @property - def body(self): - """Shortcut to access body data.""" - return self.gateway_data[SL_DATA.KEY_BODIES][self._data_key] + self._last_preset = mode.value diff --git a/homeassistant/components/screenlogic/config_flow.py b/homeassistant/components/screenlogic/config_flow.py index 77040bdb21682e..25d00e3a2ce102 100644 --- a/homeassistant/components/screenlogic/config_flow.py +++ b/homeassistant/components/screenlogic/config_flow.py @@ -2,9 +2,10 @@ from __future__ import annotations import logging +from typing import Any from screenlogicpy import ScreenLogicError, discovery -from screenlogicpy.const import SL_GATEWAY_IP, SL_GATEWAY_NAME, SL_GATEWAY_PORT +from screenlogicpy.const.common import SL_GATEWAY_IP, SL_GATEWAY_NAME, SL_GATEWAY_PORT from screenlogicpy.requests import login import voluptuous as vol @@ -64,10 +65,10 @@ class ScreenlogicConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - def __init__(self): + def __init__(self) -> None: """Initialize ScreenLogic ConfigFlow.""" - self.discovered_gateways = {} - self.discovered_ip = None + self.discovered_gateways: dict[str, dict[str, Any]] = {} + self.discovered_ip: str | None = None @staticmethod @callback @@ -77,7 +78,7 @@ def async_get_options_flow( """Get the options flow for ScreenLogic.""" return ScreenLogicOptionsFlowHandler(config_entry) - async def async_step_user(self, user_input=None): + async def async_step_user(self, user_input=None) -> FlowResult: """Handle the start of the config flow.""" self.discovered_gateways = await async_discover_gateways_by_unique_id(self.hass) return await self.async_step_gateway_select() @@ -93,7 +94,7 @@ async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowRes self.context["title_placeholders"] = {"name": discovery_info.hostname} return await self.async_step_gateway_entry() - async def async_step_gateway_select(self, user_input=None): + async def async_step_gateway_select(self, user_input=None) -> FlowResult: """Handle the selection of a discovered ScreenLogic gateway.""" existing = self._async_current_ids() unconfigured_gateways = { @@ -105,7 +106,7 @@ async def async_step_gateway_select(self, user_input=None): if not unconfigured_gateways: return await self.async_step_gateway_entry() - errors = {} + errors: dict[str, str] = {} if user_input is not None: if user_input[GATEWAY_SELECT_KEY] == GATEWAY_MANUAL_ENTRY: return await self.async_step_gateway_entry() @@ -140,9 +141,9 @@ async def async_step_gateway_select(self, user_input=None): description_placeholders={}, ) - async def async_step_gateway_entry(self, user_input=None): + async def async_step_gateway_entry(self, user_input=None) -> FlowResult: """Handle the manual entry of a ScreenLogic gateway.""" - errors = {} + errors: dict[str, str] = {} ip_address = self.discovered_ip port = 80 @@ -186,7 +187,7 @@ def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Init the screen logic options flow.""" self.config_entry = config_entry - async def async_step_init(self, user_input=None): + async def async_step_init(self, user_input=None) -> FlowResult: """Manage the options.""" if user_input is not None: return self.async_create_entry( diff --git a/homeassistant/components/screenlogic/const.py b/homeassistant/components/screenlogic/const.py index e4a5ea82186e8f..8181e0f612aa10 100644 --- a/homeassistant/components/screenlogic/const.py +++ b/homeassistant/components/screenlogic/const.py @@ -1,25 +1,48 @@ """Constants for the ScreenLogic integration.""" -from screenlogicpy.const import CIRCUIT_FUNCTION, COLOR_MODE +from screenlogicpy.const.common import UNIT +from screenlogicpy.device_const.circuit import FUNCTION +from screenlogicpy.device_const.system import COLOR_MODE +from homeassistant.const import ( + CONCENTRATION_PARTS_PER_MILLION, + PERCENTAGE, + REVOLUTIONS_PER_MINUTE, + UnitOfElectricPotential, + UnitOfPower, + UnitOfTemperature, + UnitOfTime, +) from homeassistant.util import slugify +ScreenLogicDataPath = tuple[str | int, ...] + DOMAIN = "screenlogic" DEFAULT_SCAN_INTERVAL = 30 MIN_SCAN_INTERVAL = 10 SERVICE_SET_COLOR_MODE = "set_color_mode" ATTR_COLOR_MODE = "color_mode" -SUPPORTED_COLOR_MODES = { - slugify(name): num for num, name in COLOR_MODE.NAME_FOR_NUM.items() -} +SUPPORTED_COLOR_MODES = {slugify(cm.name): cm.value for cm in COLOR_MODE} LIGHT_CIRCUIT_FUNCTIONS = { - CIRCUIT_FUNCTION.COLOR_WHEEL, - CIRCUIT_FUNCTION.DIMMER, - CIRCUIT_FUNCTION.INTELLIBRITE, - CIRCUIT_FUNCTION.LIGHT, - CIRCUIT_FUNCTION.MAGICSTREAM, - CIRCUIT_FUNCTION.PHOTONGEN, - CIRCUIT_FUNCTION.SAL_LIGHT, - CIRCUIT_FUNCTION.SAM_LIGHT, + FUNCTION.COLOR_WHEEL, + FUNCTION.DIMMER, + FUNCTION.INTELLIBRITE, + FUNCTION.LIGHT, + FUNCTION.MAGICSTREAM, + FUNCTION.PHOTONGEN, + FUNCTION.SAL_LIGHT, + FUNCTION.SAM_LIGHT, +} + +SL_UNIT_TO_HA_UNIT = { + UNIT.CELSIUS: UnitOfTemperature.CELSIUS, + UNIT.FAHRENHEIT: UnitOfTemperature.FAHRENHEIT, + UNIT.MILLIVOLT: UnitOfElectricPotential.MILLIVOLT, + UNIT.WATT: UnitOfPower.WATT, + UNIT.HOUR: UnitOfTime.HOURS, + UNIT.SECOND: UnitOfTime.SECONDS, + UNIT.REVOLUTIONS_PER_MINUTE: REVOLUTIONS_PER_MINUTE, + UNIT.PARTS_PER_MILLION: CONCENTRATION_PARTS_PER_MILLION, + UNIT.PERCENT: PERCENTAGE, } diff --git a/homeassistant/components/screenlogic/coordinator.py b/homeassistant/components/screenlogic/coordinator.py new file mode 100644 index 00000000000000..74f4992717152b --- /dev/null +++ b/homeassistant/components/screenlogic/coordinator.py @@ -0,0 +1,97 @@ +"""ScreenlogicDataUpdateCoordinator definition.""" +from datetime import timedelta +import logging + +from screenlogicpy import ScreenLogicError, ScreenLogicGateway +from screenlogicpy.const.common import SL_GATEWAY_IP, SL_GATEWAY_NAME, SL_GATEWAY_PORT +from screenlogicpy.device_const.system import EQUIPMENT_FLAG + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT, CONF_SCAN_INTERVAL +from homeassistant.core import HomeAssistant +from homeassistant.helpers.debounce import Debouncer +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .config_flow import async_discover_gateways_by_unique_id, name_for_mac +from .const import DEFAULT_SCAN_INTERVAL, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +REQUEST_REFRESH_DELAY = 2 +HEATER_COOLDOWN_DELAY = 6 + + +async def async_get_connect_info( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, str | int]: + """Construct connect_info from configuration entry and returns it to caller.""" + mac = entry.unique_id + # Attempt to rediscover gateway to follow IP changes + discovered_gateways = await async_discover_gateways_by_unique_id(hass) + if mac in discovered_gateways: + return discovered_gateways[mac] + + _LOGGER.debug("Gateway rediscovery failed for %s", entry.title) + # Static connection defined or fallback from discovery + return { + SL_GATEWAY_NAME: name_for_mac(mac), + SL_GATEWAY_IP: entry.data[CONF_IP_ADDRESS], + SL_GATEWAY_PORT: entry.data[CONF_PORT], + } + + +class ScreenlogicDataUpdateCoordinator(DataUpdateCoordinator[None]): + """Class to manage the data update for the Screenlogic component.""" + + def __init__( + self, + hass: HomeAssistant, + *, + config_entry: ConfigEntry, + gateway: ScreenLogicGateway, + ) -> None: + """Initialize the Screenlogic Data Update Coordinator.""" + self.config_entry = config_entry + self.gateway = gateway + + interval = timedelta( + seconds=config_entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + ) + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=interval, + # Debounced option since the device takes + # a moment to reflect the knock-on changes + request_refresh_debouncer=Debouncer( + hass, _LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=False + ), + ) + + async def _async_update_configured_data(self) -> None: + """Update data sets based on equipment config.""" + if not self.gateway.is_client: + await self.gateway.async_get_status() + if EQUIPMENT_FLAG.INTELLICHEM in self.gateway.equipment_flags: + await self.gateway.async_get_chemistry() + + await self.gateway.async_get_pumps() + if EQUIPMENT_FLAG.CHLORINATOR in self.gateway.equipment_flags: + await self.gateway.async_get_scg() + + async def _async_update_data(self) -> None: + """Fetch data from the Screenlogic gateway.""" + assert self.config_entry is not None + try: + if not self.gateway.is_connected: + connect_info = await async_get_connect_info( + self.hass, self.config_entry + ) + await self.gateway.async_connect(**connect_info) + + await self._async_update_configured_data() + except ScreenLogicError as ex: + if self.gateway.is_connected: + await self.gateway.async_disconnect() + raise UpdateFailed(ex.msg) from ex diff --git a/homeassistant/components/screenlogic/data.py b/homeassistant/components/screenlogic/data.py new file mode 100644 index 00000000000000..5679b7e4dc99d3 --- /dev/null +++ b/homeassistant/components/screenlogic/data.py @@ -0,0 +1,304 @@ +"""Support for configurable supported data values for the ScreenLogic integration.""" +from collections.abc import Callable, Generator +from dataclasses import dataclass +from enum import StrEnum +from typing import Any + +from screenlogicpy import ScreenLogicGateway +from screenlogicpy.const.data import ATTR, DEVICE, VALUE +from screenlogicpy.const.msg import CODE +from screenlogicpy.device_const.system import EQUIPMENT_FLAG + +from homeassistant.const import EntityCategory + +from .const import SL_UNIT_TO_HA_UNIT, ScreenLogicDataPath + + +class PathPart(StrEnum): + """Placeholders for local data_path values.""" + + DEVICE = "!device" + KEY = "!key" + INDEX = "!index" + VALUE = "!sensor" + + +ScreenLogicDataPathTemplate = tuple[PathPart | str | int, ...] + + +class ScreenLogicRule: + """Represents a base default passing rule.""" + + def __init__( + self, test: Callable[..., bool] = lambda gateway, data_path: True + ) -> None: + """Initialize a ScreenLogic rule.""" + self._test = test + + def test(self, gateway: ScreenLogicGateway, data_path: ScreenLogicDataPath) -> bool: + """Method to check the rule.""" + return self._test(gateway, data_path) + + +class ScreenLogicDataRule(ScreenLogicRule): + """Represents a data rule.""" + + def __init__( + self, test: Callable[..., bool], test_path_template: tuple[PathPart, ...] + ) -> None: + """Initialize a ScreenLogic data rule.""" + self._test_path_template = test_path_template + super().__init__(test) + + def test(self, gateway: ScreenLogicGateway, data_path: ScreenLogicDataPath) -> bool: + """Check the rule against the gateway's data.""" + test_path = realize_path_template(self._test_path_template, data_path) + return self._test(gateway.get_data(*test_path)) + + +class ScreenLogicEquipmentRule(ScreenLogicRule): + """Represents an equipment flag rule.""" + + def test(self, gateway: ScreenLogicGateway, data_path: ScreenLogicDataPath) -> bool: + """Check the rule against the gateway's equipment flags.""" + return self._test(gateway.equipment_flags) + + +@dataclass +class SupportedValueParameters: + """Base supported values for ScreenLogic Entities.""" + + enabled: ScreenLogicRule = ScreenLogicRule() + included: ScreenLogicRule = ScreenLogicRule() + subscription_code: int | None = None + entity_category: EntityCategory | None = EntityCategory.DIAGNOSTIC + + +SupportedValueDescriptions = dict[str, SupportedValueParameters] + +SupportedGroupDescriptions = dict[int | str, SupportedValueDescriptions] + +SupportedDeviceDescriptions = dict[str, SupportedGroupDescriptions] + + +DEVICE_INCLUSION_RULES = { + DEVICE.PUMP: ScreenLogicDataRule( + lambda pump_data: pump_data[VALUE.DATA] != 0, + (PathPart.DEVICE, PathPart.INDEX), + ), + DEVICE.INTELLICHEM: ScreenLogicEquipmentRule( + lambda flags: EQUIPMENT_FLAG.INTELLICHEM in flags, + ), + DEVICE.SCG: ScreenLogicEquipmentRule( + lambda flags: EQUIPMENT_FLAG.CHLORINATOR in flags, + ), +} + +DEVICE_SUBSCRIPTION = { + DEVICE.CONTROLLER: CODE.STATUS_CHANGED, + DEVICE.INTELLICHEM: CODE.CHEMISTRY_CHANGED, +} + + +# not run-time +def get_ha_unit(entity_data: dict) -> StrEnum | str | None: + """Return a Home Assistant unit of measurement from a UNIT.""" + sl_unit = entity_data.get(ATTR.UNIT) + return SL_UNIT_TO_HA_UNIT.get(sl_unit, sl_unit) + + +# partial run-time +def realize_path_template( + template_path: ScreenLogicDataPathTemplate, data_path: ScreenLogicDataPath +) -> ScreenLogicDataPath: + """Create a new data path using a template and an existing data path. + + Construct new ScreenLogicDataPath from data_path using + template_path to specify values from data_path. + """ + if not data_path or len(data_path) < 3: + raise KeyError( + f"Missing or invalid required parameter: 'data_path' for template path '{template_path}'" + ) + device, group, data_key = data_path + realized_path: list[str | int] = [] + for part in template_path: + match part: + case PathPart.DEVICE: + realized_path.append(device) + case PathPart.INDEX | PathPart.KEY: + realized_path.append(group) + case PathPart.VALUE: + realized_path.append(data_key) + case _: + realized_path.append(part) + + return tuple(realized_path) + + +def preprocess_supported_values( + supported_devices: SupportedDeviceDescriptions, +) -> list[tuple[ScreenLogicDataPath, Any]]: + """Expand config dict into list of ScreenLogicDataPaths and settings.""" + processed: list[tuple[ScreenLogicDataPath, Any]] = [] + for device, device_groups in supported_devices.items(): + for group, group_values in device_groups.items(): + for value_key, value_params in group_values.items(): + value_data_path = (device, group, value_key) + processed.append((value_data_path, value_params)) + return processed + + +def iterate_expand_group_wildcard( + gateway: ScreenLogicGateway, + preprocessed_data: list[tuple[ScreenLogicDataPath, Any]], +) -> Generator[tuple[ScreenLogicDataPath, Any], None, None]: + """Iterate and expand any group wildcards to all available entries in gateway.""" + for data_path, value_params in preprocessed_data: + device, group, value_key = data_path + if group == "*": + for index in gateway.get_data(device): + yield ((device, index, value_key), value_params) + else: + yield (data_path, value_params) + + +def build_base_entity_description( + gateway: ScreenLogicGateway, + entity_key: str, + data_path: ScreenLogicDataPath, + value_data: dict, + value_params: SupportedValueParameters, +) -> dict: + """Build base entity description. + + Returns a dict of entity description key value pairs common to all entities. + """ + return { + "data_path": data_path, + "key": entity_key, + "entity_category": value_params.entity_category, + "entity_registry_enabled_default": value_params.enabled.test( + gateway, data_path + ), + "name": value_data.get(ATTR.NAME), + } + + +ENTITY_MIGRATIONS = { + "chem_alarm": { + "new_key": VALUE.ACTIVE_ALERT, + "old_name": "Chemistry Alarm", + "new_name": "Active Alert", + }, + "chem_calcium_harness": { + "new_key": VALUE.CALCIUM_HARNESS, + }, + "chem_current_orp": { + "new_key": VALUE.ORP_NOW, + "old_name": "Current ORP", + "new_name": "ORP Now", + }, + "chem_current_ph": { + "new_key": VALUE.PH_NOW, + "old_name": "Current pH", + "new_name": "pH Now", + }, + "chem_cya": { + "new_key": VALUE.CYA, + }, + "chem_orp_dosing_state": { + "new_key": VALUE.ORP_DOSING_STATE, + }, + "chem_orp_last_dose_time": { + "new_key": VALUE.ORP_LAST_DOSE_TIME, + }, + "chem_orp_last_dose_volume": { + "new_key": VALUE.ORP_LAST_DOSE_VOLUME, + }, + "chem_orp_setpoint": { + "new_key": VALUE.ORP_SETPOINT, + }, + "chem_orp_supply_level": { + "new_key": VALUE.ORP_SUPPLY_LEVEL, + }, + "chem_ph_dosing_state": { + "new_key": VALUE.PH_DOSING_STATE, + }, + "chem_ph_last_dose_time": { + "new_key": VALUE.PH_LAST_DOSE_TIME, + }, + "chem_ph_last_dose_volume": { + "new_key": VALUE.PH_LAST_DOSE_VOLUME, + }, + "chem_ph_probe_water_temp": { + "new_key": VALUE.PH_PROBE_WATER_TEMP, + }, + "chem_ph_setpoint": { + "new_key": VALUE.PH_SETPOINT, + }, + "chem_ph_supply_level": { + "new_key": VALUE.PH_SUPPLY_LEVEL, + }, + "chem_salt_tds_ppm": { + "new_key": VALUE.SALT_TDS_PPM, + }, + "chem_total_alkalinity": { + "new_key": VALUE.TOTAL_ALKALINITY, + }, + "currentGPM": { + "new_key": VALUE.GPM_NOW, + "old_name": "Current GPM", + "new_name": "GPM Now", + "device": DEVICE.PUMP, + }, + "currentRPM": { + "new_key": VALUE.RPM_NOW, + "old_name": "Current RPM", + "new_name": "RPM Now", + "device": DEVICE.PUMP, + }, + "currentWatts": { + "new_key": VALUE.WATTS_NOW, + "old_name": "Current Watts", + "new_name": "Watts Now", + "device": DEVICE.PUMP, + }, + "orp_alarm": { + "new_key": VALUE.ORP_LOW_ALARM, + "old_name": "ORP Alarm", + "new_name": "ORP LOW Alarm", + }, + "ph_alarm": { + "new_key": VALUE.PH_HIGH_ALARM, + "old_name": "pH Alarm", + "new_name": "pH HIGH Alarm", + }, + "scg_status": { + "new_key": VALUE.STATE, + "old_name": "SCG Status", + "new_name": "Chlorinator", + "device": DEVICE.SCG, + }, + "scg_level1": { + "new_key": VALUE.POOL_SETPOINT, + "old_name": "Pool SCG Level", + "new_name": "Pool Chlorinator Setpoint", + }, + "scg_level2": { + "new_key": VALUE.SPA_SETPOINT, + "old_name": "Spa SCG Level", + "new_name": "Spa Chlorinator Setpoint", + }, + "scg_salt_ppm": { + "new_key": VALUE.SALT_PPM, + "old_name": "SCG Salt", + "new_name": "Chlorinator Salt", + "device": DEVICE.SCG, + }, + "scg_super_chlor_timer": { + "new_key": VALUE.SUPER_CHLOR_TIMER, + "old_name": "SCG Super Chlorination Timer", + "new_name": "Super Chlorination Timer", + }, +} diff --git a/homeassistant/components/screenlogic/diagnostics.py b/homeassistant/components/screenlogic/diagnostics.py index ca949c4514c7f2..92e700239ff72d 100644 --- a/homeassistant/components/screenlogic/diagnostics.py +++ b/homeassistant/components/screenlogic/diagnostics.py @@ -5,8 +5,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from . import ScreenlogicDataUpdateCoordinator from .const import DOMAIN +from .coordinator import ScreenlogicDataUpdateCoordinator async def async_get_config_entry_diagnostics( diff --git a/homeassistant/components/screenlogic/entity.py b/homeassistant/components/screenlogic/entity.py index 955b73262a1647..a29aaa9125b480 100644 --- a/homeassistant/components/screenlogic/entity.py +++ b/homeassistant/components/screenlogic/entity.py @@ -1,52 +1,65 @@ """Base ScreenLogicEntity definitions.""" +from dataclasses import dataclass from datetime import datetime import logging from typing import Any from screenlogicpy import ScreenLogicGateway -from screenlogicpy.const import CODE, DATA as SL_DATA, EQUIPMENT, ON_OFF +from screenlogicpy.const.common import ON_OFF +from screenlogicpy.const.data import ATTR +from screenlogicpy.const.msg import CODE 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 EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import ScreenlogicDataUpdateCoordinator +from .const import ScreenLogicDataPath +from .coordinator import ScreenlogicDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) +@dataclass +class ScreenLogicEntityRequiredKeyMixin: + """Mixin for required ScreenLogic entity key.""" + + data_path: ScreenLogicDataPath + + +@dataclass +class ScreenLogicEntityDescription( + EntityDescription, ScreenLogicEntityRequiredKeyMixin +): + """Base class for a ScreenLogic entity description.""" + + class ScreenlogicEntity(CoordinatorEntity[ScreenlogicDataUpdateCoordinator]): """Base class for all ScreenLogic entities.""" + entity_description: ScreenLogicEntityDescription + _attr_has_entity_name = True + def __init__( self, coordinator: ScreenlogicDataUpdateCoordinator, - data_key: str, - enabled: bool = True, + entity_description: ScreenLogicEntityDescription, ) -> None: """Initialize of the entity.""" super().__init__(coordinator) - self._data_key = data_key - self._attr_entity_registry_enabled_default = enabled - self._attr_unique_id = f"{self.mac}_{self._data_key}" - - controller_type = self.config_data["controller_type"] - hardware_type = self.config_data["hardware_type"] - try: - equipment_model = EQUIPMENT.CONTROLLER_HARDWARE[controller_type][ - hardware_type - ] - except KeyError: - equipment_model = f"Unknown Model C:{controller_type} H:{hardware_type}" + self.entity_description = entity_description + self._data_path = self.entity_description.data_path + self._data_key = self._data_path[-1] + self._attr_unique_id = f"{self.mac}_{self.entity_description.key}" mac = self.mac assert mac is not None self._attr_device_info = DeviceInfo( connections={(dr.CONNECTION_NETWORK_MAC, mac)}, manufacturer="Pentair", - model=equipment_model, - name=self.gateway_name, + model=self.gateway.controller_model, + name=self.gateway.name, sw_version=self.gateway.version, ) @@ -56,26 +69,11 @@ def mac(self) -> str | None: assert self.coordinator.config_entry is not None return self.coordinator.config_entry.unique_id - @property - def config_data(self) -> dict[str | int, Any]: - """Shortcut for config data.""" - return self.gateway_data[SL_DATA.KEY_CONFIG] - @property def gateway(self) -> ScreenLogicGateway: """Return the gateway.""" return self.coordinator.gateway - @property - def gateway_data(self) -> dict[str | int, Any]: - """Return the gateway data.""" - return self.gateway.get_data() - - @property - def gateway_name(self) -> str: - """Return the configured name of the gateway.""" - return self.gateway.name - async def _async_refresh(self) -> None: """Refresh the data from the gateway.""" await self.coordinator.async_refresh() @@ -87,20 +85,41 @@ async def _async_refresh_timed(self, now: datetime) -> None: """Refresh from a timed called.""" await self.coordinator.async_request_refresh() + @property + def entity_data(self) -> dict: + """Shortcut to the data for this entity.""" + if (data := self.gateway.get_data(*self._data_path)) is None: + raise KeyError(f"Data not found: {self._data_path}") + return data + + +@dataclass +class ScreenLogicPushEntityRequiredKeyMixin: + """Mixin for required key for ScreenLogic push entities.""" + + subscription_code: CODE + + +@dataclass +class ScreenLogicPushEntityDescription( + ScreenLogicEntityDescription, + ScreenLogicPushEntityRequiredKeyMixin, +): + """Base class for a ScreenLogic push entity description.""" + class ScreenLogicPushEntity(ScreenlogicEntity): """Base class for all ScreenLogic push entities.""" + entity_description: ScreenLogicPushEntityDescription + def __init__( self, coordinator: ScreenlogicDataUpdateCoordinator, - data_key: str, - message_code: CODE, - enabled: bool = True, + entity_description: ScreenLogicPushEntityDescription, ) -> None: - """Initialize the entity.""" - super().__init__(coordinator, data_key, enabled) - self._update_message_code = message_code + """Initialize of the entity.""" + super().__init__(coordinator, entity_description) self._last_update_success = True @callback @@ -114,7 +133,8 @@ async def async_added_to_hass(self) -> None: await super().async_added_to_hass() self.async_on_remove( await self.gateway.async_subscribe_client( - self._async_data_updated, self._update_message_code + self._async_data_updated, + self.entity_description.subscription_code, ) ) @@ -129,17 +149,10 @@ def _handle_coordinator_update(self) -> None: class ScreenLogicCircuitEntity(ScreenLogicPushEntity): """Base class for all ScreenLogic switch and light entities.""" - _attr_has_entity_name = True - - @property - def name(self) -> str: - """Get the name of the switch.""" - return self.circuit["name"] - @property def is_on(self) -> bool: """Get whether the switch is in on state.""" - return self.circuit["value"] == ON_OFF.ON + return self.entity_data[ATTR.VALUE] == ON_OFF.ON async def async_turn_on(self, **kwargs: Any) -> None: """Send the ON command.""" @@ -149,14 +162,9 @@ async def async_turn_off(self, **kwargs: Any) -> None: """Send the OFF command.""" await self._async_set_circuit(ON_OFF.OFF) - async def _async_set_circuit(self, circuit_value: int) -> None: - if not await self.gateway.async_set_circuit(self._data_key, circuit_value): + async def _async_set_circuit(self, state: ON_OFF) -> None: + if not await self.gateway.async_set_circuit(self._data_key, state.value): raise HomeAssistantError( - f"Failed to set_circuit {self._data_key} {circuit_value}" + f"Failed to set_circuit {self._data_key} {state.value}" ) - _LOGGER.debug("Turn %s %s", self._data_key, circuit_value) - - @property - def circuit(self) -> dict[str | int, Any]: - """Shortcut to access the circuit.""" - return self.gateway_data[SL_DATA.KEY_CIRCUITS][self._data_key] + _LOGGER.debug("Set circuit %s %s", self._data_key, state.value) diff --git a/homeassistant/components/screenlogic/light.py b/homeassistant/components/screenlogic/light.py index 3eae12178decdd..3875e34fbaabc2 100644 --- a/homeassistant/components/screenlogic/light.py +++ b/homeassistant/components/screenlogic/light.py @@ -1,16 +1,23 @@ """Support for a ScreenLogic light 'circuit' switch.""" +from dataclasses import dataclass import logging -from screenlogicpy.const import CODE, DATA as SL_DATA, GENERIC_CIRCUIT_NAMES +from screenlogicpy.const.data import ATTR, DEVICE +from screenlogicpy.const.msg import CODE +from screenlogicpy.device_const.circuit import GENERIC_CIRCUIT_NAMES, INTERFACE -from homeassistant.components.light import ColorMode, LightEntity +from homeassistant.components.light import ( + ColorMode, + LightEntity, + LightEntityDescription, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ScreenlogicDataUpdateCoordinator -from .const import DOMAIN, LIGHT_CIRCUIT_FUNCTIONS -from .entity import ScreenLogicCircuitEntity +from .const import DOMAIN as SL_DOMAIN, LIGHT_CIRCUIT_FUNCTIONS +from .coordinator import ScreenlogicDataUpdateCoordinator +from .entity import ScreenLogicCircuitEntity, ScreenLogicPushEntityDescription _LOGGER = logging.getLogger(__name__) @@ -21,26 +28,45 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up entry.""" - coordinator: ScreenlogicDataUpdateCoordinator = hass.data[DOMAIN][ + entities: list[ScreenLogicLight] = [] + coordinator: ScreenlogicDataUpdateCoordinator = hass.data[SL_DOMAIN][ config_entry.entry_id ] - circuits = coordinator.gateway_data[SL_DATA.KEY_CIRCUITS] - async_add_entities( - [ + gateway = coordinator.gateway + for circuit_index, circuit_data in gateway.get_data(DEVICE.CIRCUIT).items(): + if circuit_data[ATTR.FUNCTION] not in LIGHT_CIRCUIT_FUNCTIONS: + continue + circuit_name = circuit_data[ATTR.NAME] + circuit_interface = INTERFACE(circuit_data[ATTR.INTERFACE]) + entities.append( ScreenLogicLight( coordinator, - circuit_num, - CODE.STATUS_CHANGED, - circuit["name"] not in GENERIC_CIRCUIT_NAMES, + ScreenLogicLightDescription( + subscription_code=CODE.STATUS_CHANGED, + data_path=(DEVICE.CIRCUIT, circuit_index), + key=circuit_index, + name=circuit_name, + entity_registry_enabled_default=( + circuit_name not in GENERIC_CIRCUIT_NAMES + and circuit_interface != INTERFACE.DONT_SHOW + ), + ), ) - for circuit_num, circuit in circuits.items() - if circuit["function"] in LIGHT_CIRCUIT_FUNCTIONS - ] - ) + ) + + async_add_entities(entities) + + +@dataclass +class ScreenLogicLightDescription( + LightEntityDescription, ScreenLogicPushEntityDescription +): + """Describes a ScreenLogic light entity.""" class ScreenLogicLight(ScreenLogicCircuitEntity, LightEntity): """Class to represent a ScreenLogic Light.""" + entity_description: ScreenLogicLightDescription _attr_color_mode = ColorMode.ONOFF _attr_supported_color_modes = {ColorMode.ONOFF} diff --git a/homeassistant/components/screenlogic/manifest.json b/homeassistant/components/screenlogic/manifest.json index 5b8b83694274c8..9fc103dc8a8a7a 100644 --- a/homeassistant/components/screenlogic/manifest.json +++ b/homeassistant/components/screenlogic/manifest.json @@ -15,5 +15,5 @@ "documentation": "https://www.home-assistant.io/integrations/screenlogic", "iot_class": "local_push", "loggers": ["screenlogicpy"], - "requirements": ["screenlogicpy==0.8.2"] + "requirements": ["screenlogicpy==0.9.0"] } diff --git a/homeassistant/components/screenlogic/number.py b/homeassistant/components/screenlogic/number.py index e0d5d0e6a671f0..22805ffc3c1470 100644 --- a/homeassistant/components/screenlogic/number.py +++ b/homeassistant/components/screenlogic/number.py @@ -1,25 +1,82 @@ """Support for a ScreenLogic number entity.""" +from collections.abc import Callable +from dataclasses import dataclass import logging -from screenlogicpy.const import BODY_TYPE, DATA as SL_DATA, EQUIPMENT, SCG +from screenlogicpy.const.data import ATTR, DEVICE, GROUP, VALUE -from homeassistant.components.number import NumberEntity +from homeassistant.components.number import ( + DOMAIN, + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ScreenlogicDataUpdateCoordinator -from .const import DOMAIN -from .entity import ScreenlogicEntity +from .const import DOMAIN as SL_DOMAIN, ScreenLogicDataPath +from .coordinator import ScreenlogicDataUpdateCoordinator +from .data import ( + DEVICE_INCLUSION_RULES, + PathPart, + SupportedValueParameters, + build_base_entity_description, + get_ha_unit, + iterate_expand_group_wildcard, + preprocess_supported_values, + realize_path_template, +) +from .entity import ScreenlogicEntity, ScreenLogicEntityDescription +from .util import cleanup_excluded_entity, generate_unique_id _LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 1 -SUPPORTED_SCG_NUMBERS = ( - "scg_level1", - "scg_level2", + +@dataclass +class SupportedNumberValueParametersMixin: + """Mixin for supported predefined data for a ScreenLogic number entity.""" + + set_value_config: tuple[str, tuple[tuple[PathPart | str | int, ...], ...]] + device_class: NumberDeviceClass | None = None + + +@dataclass +class SupportedNumberValueParameters( + SupportedValueParameters, SupportedNumberValueParametersMixin +): + """Supported predefined data for a ScreenLogic number entity.""" + + +SET_SCG_CONFIG_FUNC_DATA = ( + "async_set_scg_config", + ( + (DEVICE.SCG, GROUP.CONFIGURATION, VALUE.POOL_SETPOINT), + (DEVICE.SCG, GROUP.CONFIGURATION, VALUE.SPA_SETPOINT), + ), +) + + +SUPPORTED_DATA: list[ + tuple[ScreenLogicDataPath, SupportedValueParameters] +] = preprocess_supported_values( + { + DEVICE.SCG: { + GROUP.CONFIGURATION: { + VALUE.POOL_SETPOINT: SupportedNumberValueParameters( + entity_category=EntityCategory.CONFIG, + set_value_config=SET_SCG_CONFIG_FUNC_DATA, + ), + VALUE.SPA_SETPOINT: SupportedNumberValueParameters( + entity_category=EntityCategory.CONFIG, + set_value_config=SET_SCG_CONFIG_FUNC_DATA, + ), + } + } + } ) @@ -29,66 +86,113 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up entry.""" - coordinator: ScreenlogicDataUpdateCoordinator = hass.data[DOMAIN][ + entities: list[ScreenLogicNumber] = [] + coordinator: ScreenlogicDataUpdateCoordinator = hass.data[SL_DOMAIN][ config_entry.entry_id ] - equipment_flags = coordinator.gateway_data[SL_DATA.KEY_CONFIG]["equipment_flags"] - if equipment_flags & EQUIPMENT.FLAG_CHLORINATOR: - async_add_entities( - [ - ScreenLogicNumber(coordinator, scg_level) - for scg_level in coordinator.gateway_data[SL_DATA.KEY_SCG] - if scg_level in SUPPORTED_SCG_NUMBERS - ] + gateway = coordinator.gateway + data_path: ScreenLogicDataPath + value_params: SupportedNumberValueParameters + for data_path, value_params in iterate_expand_group_wildcard( + gateway, SUPPORTED_DATA + ): + entity_key = generate_unique_id(*data_path) + + device = data_path[0] + + if not (DEVICE_INCLUSION_RULES.get(device) or value_params.included).test( + gateway, data_path + ): + cleanup_excluded_entity(coordinator, DOMAIN, entity_key) + continue + + try: + value_data = gateway.get_data(*data_path, strict=True) + except KeyError: + _LOGGER.debug("Failed to find %s", data_path) + continue + + set_value_str, set_value_params = value_params.set_value_config + set_value_func = getattr(gateway, set_value_str) + + entity_description_kwargs = { + **build_base_entity_description( + gateway, entity_key, data_path, value_data, value_params + ), + "device_class": value_params.device_class, + "native_unit_of_measurement": get_ha_unit(value_data), + "native_max_value": value_data.get(ATTR.MAX_SETPOINT), + "native_min_value": value_data.get(ATTR.MIN_SETPOINT), + "native_step": value_data.get(ATTR.STEP), + "set_value": set_value_func, + "set_value_params": set_value_params, + } + + entities.append( + ScreenLogicNumber( + coordinator, + ScreenLogicNumberDescription(**entity_description_kwargs), + ) ) + async_add_entities(entities) + + +@dataclass +class ScreenLogicNumberRequiredMixin: + """Describes a required mixin for a ScreenLogic number entity.""" + + set_value: Callable[..., bool] + set_value_params: tuple[tuple[str | int, ...], ...] + + +@dataclass +class ScreenLogicNumberDescription( + NumberEntityDescription, + ScreenLogicEntityDescription, + ScreenLogicNumberRequiredMixin, +): + """Describes a ScreenLogic number entity.""" + class ScreenLogicNumber(ScreenlogicEntity, NumberEntity): - """Class to represent a ScreenLogic Number.""" + """Class to represent a ScreenLogic Number entity.""" - _attr_has_entity_name = True + entity_description: ScreenLogicNumberDescription - def __init__(self, coordinator, data_key, enabled=True): - """Initialize of the entity.""" - super().__init__(coordinator, data_key, enabled) - self._body_type = SUPPORTED_SCG_NUMBERS.index(self._data_key) - self._attr_native_max_value = SCG.LIMIT_FOR_BODY[self._body_type] - self._attr_name = self.sensor["name"] - self._attr_native_unit_of_measurement = self.sensor["unit"] - self._attr_entity_category = EntityCategory.CONFIG + def __init__( + self, + coordinator: ScreenlogicDataUpdateCoordinator, + entity_description: ScreenLogicNumberDescription, + ) -> None: + """Initialize a ScreenLogic number entity.""" + self._set_value_func = entity_description.set_value + self._set_value_params = entity_description.set_value_params + super().__init__(coordinator, entity_description) @property def native_value(self) -> float: """Return the current value.""" - return self.sensor["value"] + return self.entity_data[ATTR.VALUE] async def async_set_native_value(self, value: float) -> None: """Update the current value.""" - # Need to set both levels at the same time, so we gather - # both existing level values and override the one that changed. - levels = {} - for level in SUPPORTED_SCG_NUMBERS: - levels[level] = self.gateway_data[SL_DATA.KEY_SCG][level]["value"] - levels[self._data_key] = int(value) - - if await self.coordinator.gateway.async_set_scg_config( - levels[SUPPORTED_SCG_NUMBERS[BODY_TYPE.POOL]], - levels[SUPPORTED_SCG_NUMBERS[BODY_TYPE.SPA]], - ): - _LOGGER.debug( - "Set SCG to %i, %i", - levels[SUPPORTED_SCG_NUMBERS[BODY_TYPE.POOL]], - levels[SUPPORTED_SCG_NUMBERS[BODY_TYPE.SPA]], + + # Current API requires certain values to be set at the same time. This + # gathers the existing values and updates the particular value being + # set by this entity. + args = {} + for data_path in self._set_value_params: + data_path = realize_path_template(data_path, self._data_path) + data_value = data_path[-1] + args[data_value] = self.coordinator.gateway.get_value( + *data_path, strict=True ) + + args[self._data_key] = value + + if self._set_value_func(*args.values()): + _LOGGER.debug("Set '%s' to %s", self._data_key, value) await self._async_refresh() else: - _LOGGER.warning( - "Failed to set_scg to %i, %i", - levels[SUPPORTED_SCG_NUMBERS[BODY_TYPE.POOL]], - levels[SUPPORTED_SCG_NUMBERS[BODY_TYPE.SPA]], - ) - - @property - def sensor(self) -> dict: - """Shortcut to access the level sensor data.""" - return self.gateway_data[SL_DATA.KEY_SCG][self._data_key] + _LOGGER.debug("Failed to set '%s' to %s", self._data_key, value) diff --git a/homeassistant/components/screenlogic/sensor.py b/homeassistant/components/screenlogic/sensor.py index 3a9bc3cbee97cc..39805173961358 100644 --- a/homeassistant/components/screenlogic/sensor.py +++ b/homeassistant/components/screenlogic/sensor.py @@ -1,75 +1,147 @@ """Support for a ScreenLogic Sensor.""" -from typing import Any - -from screenlogicpy.const import ( - CHEM_DOSING_STATE, - CODE, - DATA as SL_DATA, - DEVICE_TYPE, - EQUIPMENT, - STATE_TYPE, - UNIT, -) +from collections.abc import Callable +from dataclasses import dataclass +import logging + +from screenlogicpy.const.common import DEVICE_TYPE, STATE_TYPE +from screenlogicpy.const.data import ATTR, DEVICE, GROUP, VALUE +from screenlogicpy.device_const.chemistry import DOSE_STATE +from screenlogicpy.device_const.pump import PUMP_TYPE +from screenlogicpy.device_const.system import EQUIPMENT_FLAG from homeassistant.components.sensor import ( + DOMAIN, SensorDeviceClass, SensorEntity, + SensorEntityDescription, SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONCENTRATION_PARTS_PER_MILLION, - PERCENTAGE, - REVOLUTIONS_PER_MINUTE, - EntityCategory, - UnitOfElectricPotential, - UnitOfPower, - UnitOfTemperature, - UnitOfTime, -) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ScreenlogicDataUpdateCoordinator -from .const import DOMAIN -from .entity import ScreenlogicEntity, ScreenLogicPushEntity - -SUPPORTED_BASIC_SENSORS = ( - "air_temperature", - "saturation", +from .const import DOMAIN as SL_DOMAIN, ScreenLogicDataPath +from .coordinator import ScreenlogicDataUpdateCoordinator +from .data import ( + DEVICE_INCLUSION_RULES, + DEVICE_SUBSCRIPTION, + PathPart, + ScreenLogicDataRule, + ScreenLogicEquipmentRule, + SupportedValueParameters, + build_base_entity_description, + get_ha_unit, + iterate_expand_group_wildcard, + preprocess_supported_values, ) - -SUPPORTED_BASIC_CHEM_SENSORS = ( - "orp", - "ph", +from .entity import ( + ScreenlogicEntity, + ScreenLogicEntityDescription, + ScreenLogicPushEntity, + ScreenLogicPushEntityDescription, ) +from .util import cleanup_excluded_entity, generate_unique_id -SUPPORTED_CHEM_SENSORS = ( - "calcium_harness", - "current_orp", - "current_ph", - "cya", - "orp_dosing_state", - "orp_last_dose_time", - "orp_last_dose_volume", - "orp_setpoint", - "orp_supply_level", - "ph_dosing_state", - "ph_last_dose_time", - "ph_last_dose_volume", - "ph_probe_water_temp", - "ph_setpoint", - "ph_supply_level", - "salt_tds_ppm", - "total_alkalinity", -) +_LOGGER = logging.getLogger(__name__) -SUPPORTED_SCG_SENSORS = ( - "scg_salt_ppm", - "scg_super_chlor_timer", -) -SUPPORTED_PUMP_SENSORS = ("currentWatts", "currentRPM", "currentGPM") +@dataclass +class SupportedSensorValueParameters(SupportedValueParameters): + """Supported predefined data for a ScreenLogic sensor entity.""" + + device_class: SensorDeviceClass | None = None + value_modification: Callable[[int], int | str] | None = lambda val: val + + +SUPPORTED_DATA: list[ + tuple[ScreenLogicDataPath, SupportedValueParameters] +] = preprocess_supported_values( + { + DEVICE.CONTROLLER: { + GROUP.SENSOR: { + VALUE.AIR_TEMPERATURE: SupportedSensorValueParameters( + device_class=SensorDeviceClass.TEMPERATURE, entity_category=None + ), + VALUE.ORP: SupportedSensorValueParameters( + included=ScreenLogicEquipmentRule( + lambda flags: EQUIPMENT_FLAG.INTELLICHEM in flags + ) + ), + VALUE.PH: SupportedSensorValueParameters( + included=ScreenLogicEquipmentRule( + lambda flags: EQUIPMENT_FLAG.INTELLICHEM in flags + ) + ), + }, + }, + DEVICE.PUMP: { + "*": { + VALUE.WATTS_NOW: SupportedSensorValueParameters(), + VALUE.GPM_NOW: SupportedSensorValueParameters( + enabled=ScreenLogicDataRule( + lambda pump_data: pump_data[VALUE.TYPE] + != PUMP_TYPE.INTELLIFLO_VS, + (PathPart.DEVICE, PathPart.INDEX), + ) + ), + VALUE.RPM_NOW: SupportedSensorValueParameters( + enabled=ScreenLogicDataRule( + lambda pump_data: pump_data[VALUE.TYPE] + != PUMP_TYPE.INTELLIFLO_VF, + (PathPart.DEVICE, PathPart.INDEX), + ) + ), + }, + }, + DEVICE.INTELLICHEM: { + GROUP.SENSOR: { + VALUE.ORP_NOW: SupportedSensorValueParameters(), + VALUE.ORP_SUPPLY_LEVEL: SupportedSensorValueParameters( + value_modification=lambda val: val - 1 + ), + VALUE.PH_NOW: SupportedSensorValueParameters(), + VALUE.PH_PROBE_WATER_TEMP: SupportedSensorValueParameters(), + VALUE.PH_SUPPLY_LEVEL: SupportedSensorValueParameters( + value_modification=lambda val: val - 1 + ), + VALUE.SATURATION: SupportedSensorValueParameters(), + }, + GROUP.CONFIGURATION: { + VALUE.CALCIUM_HARNESS: SupportedSensorValueParameters(), + VALUE.CYA: SupportedSensorValueParameters(), + VALUE.ORP_SETPOINT: SupportedSensorValueParameters(), + VALUE.PH_SETPOINT: SupportedSensorValueParameters(), + VALUE.SALT_TDS_PPM: SupportedSensorValueParameters( + included=ScreenLogicEquipmentRule( + lambda flags: EQUIPMENT_FLAG.INTELLICHEM in flags + and EQUIPMENT_FLAG.CHLORINATOR not in flags, + ) + ), + VALUE.TOTAL_ALKALINITY: SupportedSensorValueParameters(), + }, + GROUP.DOSE_STATUS: { + VALUE.ORP_DOSING_STATE: SupportedSensorValueParameters( + value_modification=lambda val: DOSE_STATE(val).title, + ), + VALUE.ORP_LAST_DOSE_TIME: SupportedSensorValueParameters(), + VALUE.ORP_LAST_DOSE_VOLUME: SupportedSensorValueParameters(), + VALUE.PH_DOSING_STATE: SupportedSensorValueParameters( + value_modification=lambda val: DOSE_STATE(val).title, + ), + VALUE.PH_LAST_DOSE_TIME: SupportedSensorValueParameters(), + VALUE.PH_LAST_DOSE_VOLUME: SupportedSensorValueParameters(), + }, + }, + DEVICE.SCG: { + GROUP.SENSOR: { + VALUE.SALT_PPM: SupportedSensorValueParameters(), + }, + GROUP.CONFIGURATION: { + VALUE.SUPER_CHLOR_TIMER: SupportedSensorValueParameters(), + }, + }, + } +) SL_DEVICE_TYPE_TO_HA_DEVICE_CLASS = { DEVICE_TYPE.DURATION: SensorDeviceClass.DURATION, @@ -85,18 +157,6 @@ STATE_TYPE.TOTAL_INCREASING: SensorStateClass.TOTAL_INCREASING, } -SL_UNIT_TO_HA_UNIT = { - UNIT.CELSIUS: UnitOfTemperature.CELSIUS, - UNIT.FAHRENHEIT: UnitOfTemperature.FAHRENHEIT, - UNIT.MILLIVOLT: UnitOfElectricPotential.MILLIVOLT, - UNIT.WATT: UnitOfPower.WATT, - UNIT.HOUR: UnitOfTime.HOURS, - UNIT.SECOND: UnitOfTime.SECONDS, - UNIT.REVOLUTIONS_PER_MINUTE: REVOLUTIONS_PER_MINUTE, - UNIT.PARTS_PER_MILLION: CONCENTRATION_PARTS_PER_MILLION, - UNIT.PERCENT: PERCENTAGE, -} - async def async_setup_entry( hass: HomeAssistant, @@ -104,171 +164,110 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up entry.""" - entities: list[ScreenLogicSensorEntity] = [] - coordinator: ScreenlogicDataUpdateCoordinator = hass.data[DOMAIN][ + entities: list[ScreenLogicSensor] = [] + coordinator: ScreenlogicDataUpdateCoordinator = hass.data[SL_DOMAIN][ config_entry.entry_id ] - equipment_flags = coordinator.gateway_data[SL_DATA.KEY_CONFIG]["equipment_flags"] - - # Generic push sensors - for sensor_name in coordinator.gateway_data[SL_DATA.KEY_SENSORS]: - if sensor_name in SUPPORTED_BASIC_SENSORS: - entities.append( - ScreenLogicStatusSensor(coordinator, sensor_name, CODE.STATUS_CHANGED) - ) + gateway = coordinator.gateway + data_path: ScreenLogicDataPath + value_params: SupportedSensorValueParameters + for data_path, value_params in iterate_expand_group_wildcard( + gateway, SUPPORTED_DATA + ): + entity_key = generate_unique_id(*data_path) + + device = data_path[0] + + if not (DEVICE_INCLUSION_RULES.get(device) or value_params.included).test( + gateway, data_path + ): + cleanup_excluded_entity(coordinator, DOMAIN, entity_key) + continue + + try: + value_data = gateway.get_data(*data_path, strict=True) + except KeyError: + _LOGGER.debug("Failed to find %s", data_path) + continue + + entity_description_kwargs = { + **build_base_entity_description( + gateway, entity_key, data_path, value_data, value_params + ), + "device_class": SL_DEVICE_TYPE_TO_HA_DEVICE_CLASS.get( + value_data.get(ATTR.DEVICE_TYPE) + ), + "native_unit_of_measurement": get_ha_unit(value_data), + "options": value_data.get(ATTR.ENUM_OPTIONS), + "state_class": SL_STATE_TYPE_TO_HA_STATE_CLASS.get( + value_data.get(ATTR.STATE_TYPE) + ), + "value_mod": value_params.value_modification, + } - # While these values exist in the chemistry data, their last value doesn't - # persist there when the pump is off/there is no flow. Pulling them from - # the basic sensors keeps the 'last' value and is better for graphs. if ( - equipment_flags & EQUIPMENT.FLAG_INTELLICHEM - and sensor_name in SUPPORTED_BASIC_CHEM_SENSORS - ): + sub_code := ( + value_params.subscription_code or DEVICE_SUBSCRIPTION.get(device) + ) + ) is not None: entities.append( - ScreenLogicStatusSensor(coordinator, sensor_name, CODE.STATUS_CHANGED) + ScreenLogicPushSensor( + coordinator, + ScreenLogicPushSensorDescription( + subscription_code=sub_code, + **entity_description_kwargs, + ), + ) ) - - # Pump sensors - for pump_num, pump_data in coordinator.gateway_data[SL_DATA.KEY_PUMPS].items(): - if pump_data["data"] != 0 and "currentWatts" in pump_data: - for pump_key in pump_data: - enabled = True - # Assumptions for Intelliflow VF - if pump_data["pumpType"] == 1 and pump_key == "currentRPM": - enabled = False - # Assumptions for Intelliflow VS - if pump_data["pumpType"] == 2 and pump_key == "currentGPM": - enabled = False - if pump_key in SUPPORTED_PUMP_SENSORS: - entities.append( - ScreenLogicPumpSensor(coordinator, pump_num, pump_key, enabled) - ) - - # IntelliChem sensors - if equipment_flags & EQUIPMENT.FLAG_INTELLICHEM: - for chem_sensor_name in coordinator.gateway_data[SL_DATA.KEY_CHEMISTRY]: - enabled = True - if equipment_flags & EQUIPMENT.FLAG_CHLORINATOR: - if chem_sensor_name in ("salt_tds_ppm",): - enabled = False - if chem_sensor_name in SUPPORTED_CHEM_SENSORS: - entities.append( - ScreenLogicChemistrySensor( - coordinator, chem_sensor_name, CODE.CHEMISTRY_CHANGED, enabled - ) + else: + entities.append( + ScreenLogicSensor( + coordinator, + ScreenLogicSensorDescription( + **entity_description_kwargs, + ), ) - - # SCG sensors - if equipment_flags & EQUIPMENT.FLAG_CHLORINATOR: - entities.extend( - [ - ScreenLogicSCGSensor(coordinator, scg_sensor) - for scg_sensor in coordinator.gateway_data[SL_DATA.KEY_SCG] - if scg_sensor in SUPPORTED_SCG_SENSORS - ] - ) + ) async_add_entities(entities) -class ScreenLogicSensorEntity(ScreenlogicEntity, SensorEntity): - """Base class for all ScreenLogic sensor entities.""" +@dataclass +class ScreenLogicSensorMixin: + """Mixin for SecreenLogic sensor entity.""" - _attr_has_entity_name = True + value_mod: Callable[[int | str], int | str] | None = None - @property - def name(self) -> str | None: - """Name of the sensor.""" - return self.sensor["name"] - @property - def native_unit_of_measurement(self) -> str | None: - """Return the unit of measurement.""" - sl_unit = self.sensor.get("unit") - return SL_UNIT_TO_HA_UNIT.get(sl_unit, sl_unit) +@dataclass +class ScreenLogicSensorDescription( + ScreenLogicSensorMixin, SensorEntityDescription, ScreenLogicEntityDescription +): + """Describes a ScreenLogic sensor.""" - @property - def device_class(self) -> SensorDeviceClass | None: - """Device class of the sensor.""" - device_type = self.sensor.get("device_type") - return SL_DEVICE_TYPE_TO_HA_DEVICE_CLASS.get(device_type) - @property - def entity_category(self) -> EntityCategory | None: - """Entity Category of the sensor.""" - return ( - None if self._data_key == "air_temperature" else EntityCategory.DIAGNOSTIC - ) +class ScreenLogicSensor(ScreenlogicEntity, SensorEntity): + """Representation of a ScreenLogic sensor entity.""" - @property - def state_class(self) -> SensorStateClass | None: - """Return the state class of the sensor.""" - state_type = self.sensor.get("state_type") - if self._data_key == "scg_super_chlor_timer": - return None - return SL_STATE_TYPE_TO_HA_STATE_CLASS.get(state_type) - - @property - def options(self) -> list[str] | None: - """Return a set of possible options.""" - return self.sensor.get("enum_options") + entity_description: ScreenLogicSensorDescription + _attr_has_entity_name = True @property def native_value(self) -> str | int | float: """State of the sensor.""" - return self.sensor["value"] - - @property - def sensor(self) -> dict[str | int, Any]: - """Shortcut to access the sensor data.""" - return self.gateway_data[SL_DATA.KEY_SENSORS][self._data_key] - + val = self.entity_data[ATTR.VALUE] + value_mod = self.entity_description.value_mod + return value_mod(val) if value_mod else val -class ScreenLogicStatusSensor(ScreenLogicSensorEntity, ScreenLogicPushEntity): - """Representation of a basic ScreenLogic sensor entity.""" +@dataclass +class ScreenLogicPushSensorDescription( + ScreenLogicSensorDescription, ScreenLogicPushEntityDescription +): + """Describes a ScreenLogic push sensor.""" -class ScreenLogicPumpSensor(ScreenLogicSensorEntity): - """Representation of a ScreenLogic pump sensor entity.""" - def __init__(self, coordinator, pump, key, enabled=True): - """Initialize of the pump sensor.""" - super().__init__(coordinator, f"{key}_{pump}", enabled) - self._pump_id = pump - self._key = key +class ScreenLogicPushSensor(ScreenLogicSensor, ScreenLogicPushEntity): + """Representation of a ScreenLogic push sensor entity.""" - @property - def sensor(self) -> dict[str | int, Any]: - """Shortcut to access the pump sensor data.""" - return self.gateway_data[SL_DATA.KEY_PUMPS][self._pump_id][self._key] - - -class ScreenLogicChemistrySensor(ScreenLogicSensorEntity, ScreenLogicPushEntity): - """Representation of a ScreenLogic IntelliChem sensor entity.""" - - def __init__(self, coordinator, key, message_code, enabled=True): - """Initialize of the pump sensor.""" - super().__init__(coordinator, f"chem_{key}", message_code, enabled) - self._key = key - - @property - def native_value(self) -> str | int | float: - """State of the sensor.""" - value = self.sensor["value"] - if "dosing_state" in self._key: - return CHEM_DOSING_STATE.NAME_FOR_NUM[value] - return (value - 1) if "supply" in self._data_key else value - - @property - def sensor(self) -> dict[str | int, Any]: - """Shortcut to access the pump sensor data.""" - return self.gateway_data[SL_DATA.KEY_CHEMISTRY][self._key] - - -class ScreenLogicSCGSensor(ScreenLogicSensorEntity): - """Representation of ScreenLogic SCG sensor entity.""" - - @property - def sensor(self) -> dict[str | int, Any]: - """Shortcut to access the pump sensor data.""" - return self.gateway_data[SL_DATA.KEY_SCG][self._data_key] + entity_description: ScreenLogicPushSensorDescription diff --git a/homeassistant/components/screenlogic/switch.py b/homeassistant/components/screenlogic/switch.py index 96bced70867f2c..247ec4f2f03419 100644 --- a/homeassistant/components/screenlogic/switch.py +++ b/homeassistant/components/screenlogic/switch.py @@ -1,21 +1,19 @@ """Support for a ScreenLogic 'circuit' switch.""" +from dataclasses import dataclass import logging -from screenlogicpy.const import ( - CODE, - DATA as SL_DATA, - GENERIC_CIRCUIT_NAMES, - INTERFACE_GROUP, -) +from screenlogicpy.const.data import ATTR, DEVICE +from screenlogicpy.const.msg import CODE +from screenlogicpy.device_const.circuit import GENERIC_CIRCUIT_NAMES, INTERFACE -from homeassistant.components.switch import SwitchEntity +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ScreenlogicDataUpdateCoordinator -from .const import DOMAIN, LIGHT_CIRCUIT_FUNCTIONS -from .entity import ScreenLogicCircuitEntity +from .const import DOMAIN as SL_DOMAIN, LIGHT_CIRCUIT_FUNCTIONS +from .coordinator import ScreenlogicDataUpdateCoordinator +from .entity import ScreenLogicCircuitEntity, ScreenLogicPushEntityDescription _LOGGER = logging.getLogger(__name__) @@ -26,24 +24,43 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up entry.""" - coordinator: ScreenlogicDataUpdateCoordinator = hass.data[DOMAIN][ + entities: list[ScreenLogicSwitch] = [] + coordinator: ScreenlogicDataUpdateCoordinator = hass.data[SL_DOMAIN][ config_entry.entry_id ] - circuits = coordinator.gateway_data[SL_DATA.KEY_CIRCUITS] - async_add_entities( - [ + gateway = coordinator.gateway + for circuit_index, circuit_data in gateway.get_data(DEVICE.CIRCUIT).items(): + if circuit_data[ATTR.FUNCTION] in LIGHT_CIRCUIT_FUNCTIONS: + continue + circuit_name = circuit_data[ATTR.NAME] + circuit_interface = INTERFACE(circuit_data[ATTR.INTERFACE]) + entities.append( ScreenLogicSwitch( coordinator, - circuit_num, - CODE.STATUS_CHANGED, - circuit["name"] not in GENERIC_CIRCUIT_NAMES - and circuit["interface"] != INTERFACE_GROUP.DONT_SHOW, + ScreenLogicSwitchDescription( + subscription_code=CODE.STATUS_CHANGED, + data_path=(DEVICE.CIRCUIT, circuit_index), + key=circuit_index, + name=circuit_name, + entity_registry_enabled_default=( + circuit_name not in GENERIC_CIRCUIT_NAMES + and circuit_interface != INTERFACE.DONT_SHOW + ), + ), ) - for circuit_num, circuit in circuits.items() - if circuit["function"] not in LIGHT_CIRCUIT_FUNCTIONS - ] - ) + ) + + async_add_entities(entities) + + +@dataclass +class ScreenLogicSwitchDescription( + SwitchEntityDescription, ScreenLogicPushEntityDescription +): + """Describes a ScreenLogic switch entity.""" class ScreenLogicSwitch(ScreenLogicCircuitEntity, SwitchEntity): """Class to represent a ScreenLogic Switch.""" + + entity_description: ScreenLogicSwitchDescription diff --git a/homeassistant/components/screenlogic/util.py b/homeassistant/components/screenlogic/util.py new file mode 100644 index 00000000000000..c8d9d5f0f771f9 --- /dev/null +++ b/homeassistant/components/screenlogic/util.py @@ -0,0 +1,40 @@ +"""Utility functions for the ScreenLogic integration.""" +import logging + +from screenlogicpy.const.data import SHARED_VALUES + +from homeassistant.helpers import entity_registry as er + +from .const import DOMAIN as SL_DOMAIN +from .coordinator import ScreenlogicDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +def generate_unique_id( + device: str | int, group: str | int | None, data_key: str | int +) -> str: + """Generate new unique_id for a screenlogic entity from specified parameters.""" + if data_key in SHARED_VALUES and device is not None: + if group is not None and (isinstance(group, int) or group.isdigit()): + return f"{device}_{group}_{data_key}" + return f"{device}_{data_key}" + return str(data_key) + + +def cleanup_excluded_entity( + coordinator: ScreenlogicDataUpdateCoordinator, + platform_domain: str, + entity_key: str, +) -> None: + """Remove excluded entity if it exists.""" + assert coordinator.config_entry + entity_registry = er.async_get(coordinator.hass) + unique_id = f"{coordinator.config_entry.unique_id}_{entity_key}" + if entity_id := entity_registry.async_get_entity_id( + platform_domain, SL_DOMAIN, unique_id + ): + _LOGGER.debug( + "Removing existing entity '%s' per data inclusion rule", entity_id + ) + entity_registry.async_remove(entity_id) diff --git a/homeassistant/components/script/__init__.py b/homeassistant/components/script/__init__.py index 8530aa3b04c143..716f0197c8b485 100644 --- a/homeassistant/components/script/__init__.py +++ b/homeassistant/components/script/__init__.py @@ -42,9 +42,6 @@ from homeassistant.helpers.config_validation import make_entity_service_schema from homeassistant.helpers.entity import ToggleEntity from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.helpers.integration_platform import ( - async_process_integration_platform_for_component, -) from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.script import ( ATTR_CUR, @@ -188,10 +185,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: LOGGER, DOMAIN, hass ) - # Process integration platforms right away since - # we will create entities before firing EVENT_COMPONENT_LOADED - await async_process_integration_platform_for_component(hass, DOMAIN) - # Register script as valid domain for Blueprint async_get_blueprints(hass) @@ -382,6 +375,10 @@ def find_matches( class BaseScriptEntity(ToggleEntity, ABC): """Base class for script entities.""" + _entity_component_unrecorded_attributes = frozenset( + {ATTR_LAST_TRIGGERED, ATTR_MODE, ATTR_CUR, ATTR_MAX, ATTR_LAST_ACTION} + ) + raw_config: ConfigType | None @property @@ -563,7 +560,8 @@ async def _async_start_run( ) coro = self._async_run(variables, context) if wait: - return await coro + script_result = await coro + return script_result.service_response if script_result else None # Caller does not want to wait for called script to finish so let script run in # separate Task. Make a new empty script stack; scripts are allowed to diff --git a/homeassistant/components/script/blueprints/confirmable_notification.yaml b/homeassistant/components/script/blueprints/confirmable_notification.yaml index 37e04351d9a98f..c5f42494f02659 100644 --- a/homeassistant/components/script/blueprints/confirmable_notification.yaml +++ b/homeassistant/components/script/blueprints/confirmable_notification.yaml @@ -12,7 +12,8 @@ blueprint: description: Device needs to run the official Home Assistant app to receive notifications. selector: device: - integration: mobile_app + filter: + integration: mobile_app title: name: "Title" description: "The title of the button shown in the notification." diff --git a/homeassistant/components/script/recorder.py b/homeassistant/components/script/recorder.py deleted file mode 100644 index b1afc318b51e1a..00000000000000 --- a/homeassistant/components/script/recorder.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.core import HomeAssistant, callback - -from . import ATTR_CUR, ATTR_LAST_ACTION, ATTR_LAST_TRIGGERED, ATTR_MAX, ATTR_MODE - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude extra attributes from being recorded in the database.""" - return {ATTR_LAST_TRIGGERED, ATTR_MODE, ATTR_CUR, ATTR_MAX, ATTR_LAST_ACTION} diff --git a/homeassistant/components/search/__init__.py b/homeassistant/components/search/__init__.py index 69796800e61562..ac9a13850d6329 100644 --- a/homeassistant/components/search/__init__.py +++ b/homeassistant/components/search/__init__.py @@ -15,7 +15,10 @@ device_registry as dr, entity_registry as er, ) -from homeassistant.helpers.entity import entity_sources as get_entity_sources +from homeassistant.helpers.entity import ( + EntityInfo, + entity_sources as get_entity_sources, +) from homeassistant.helpers.typing import ConfigType DOMAIN = "search" @@ -97,7 +100,7 @@ def __init__( hass: HomeAssistant, device_reg: dr.DeviceRegistry, entity_reg: er.EntityRegistry, - entity_sources: dict[str, dict[str, str]], + entity_sources: dict[str, EntityInfo], ) -> None: """Search results.""" self.hass = hass diff --git a/homeassistant/components/select/__init__.py b/homeassistant/components/select/__init__.py index a8034588ed1f39..4997e088a54b08 100644 --- a/homeassistant/components/select/__init__.py +++ b/homeassistant/components/select/__init__.py @@ -128,6 +128,8 @@ class SelectEntityDescription(EntityDescription): class SelectEntity(Entity): """Representation of a Select entity.""" + _entity_component_unrecorded_attributes = frozenset({ATTR_OPTIONS}) + entity_description: SelectEntityDescription _attr_current_option: str | None _attr_options: list[str] diff --git a/homeassistant/components/select/recorder.py b/homeassistant/components/select/recorder.py deleted file mode 100644 index 6660c8383d042d..00000000000000 --- a/homeassistant/components/select/recorder.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.core import HomeAssistant, callback - -from . import ATTR_OPTIONS - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude static attributes from being recorded in the database.""" - return {ATTR_OPTIONS} diff --git a/homeassistant/components/sense/binary_sensor.py b/homeassistant/components/sense/binary_sensor.py index 2aee20be5aecf2..094ecbdfcf753d 100644 --- a/homeassistant/components/sense/binary_sensor.py +++ b/homeassistant/components/sense/binary_sensor.py @@ -74,53 +74,23 @@ class SenseDevice(BinarySensorEntity): _attr_attribution = ATTRIBUTION _attr_should_poll = False + _attr_available = False + _attr_device_class = BinarySensorDeviceClass.POWER def __init__(self, sense_devices_data, device, sense_monitor_id): """Initialize the Sense binary sensor.""" - self._name = device["name"] + self._attr_name = device["name"] self._id = device["id"] self._sense_monitor_id = sense_monitor_id - self._unique_id = f"{sense_monitor_id}-{self._id}" - self._icon = sense_to_mdi(device["icon"]) + self._attr_unique_id = f"{sense_monitor_id}-{self._id}" + self._attr_icon = sense_to_mdi(device["icon"]) self._sense_devices_data = sense_devices_data - self._state = None - self._available = False - - @property - def is_on(self): - """Return true if the binary sensor is on.""" - return self._state - - @property - def available(self): - """Return the availability of the binary sensor.""" - return self._available - - @property - def name(self): - """Return the name of the binary sensor.""" - return self._name - - @property - def unique_id(self): - """Return the unique id of the binary sensor.""" - return self._unique_id @property def old_unique_id(self): """Return the old not so unique id of the binary sensor.""" return self._id - @property - def icon(self): - """Return the icon of the binary sensor.""" - return self._icon - - @property - def device_class(self): - """Return the device class of the binary sensor.""" - return BinarySensorDeviceClass.POWER - async def async_added_to_hass(self) -> None: """Register callbacks.""" self.async_on_remove( @@ -135,8 +105,8 @@ async def async_added_to_hass(self) -> None: def _async_update_from_data(self): """Get the latest data, update state. Must not do I/O.""" new_state = bool(self._sense_devices_data.get_device_by_id(self._id)) - if self._available and self._state == new_state: + if self._attr_available and self._attr_is_on == new_state: return - self._available = True - self._state = new_state + self._attr_available = True + self._attr_is_on = new_state self.async_write_ha_state() diff --git a/homeassistant/components/sense/manifest.json b/homeassistant/components/sense/manifest.json index 8c20db2e422466..7ef1caefe48907 100644 --- a/homeassistant/components/sense/manifest.json +++ b/homeassistant/components/sense/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/sense", "iot_class": "cloud_polling", "loggers": ["sense_energy"], - "requirements": ["sense-energy==0.12.0"] + "requirements": ["sense-energy==0.12.2"] } diff --git a/homeassistant/components/sensibo/climate.py b/homeassistant/components/sensibo/climate.py index da86ba8fe2464e..3529627b49788c 100644 --- a/homeassistant/components/sensibo/climate.py +++ b/homeassistant/components/sensibo/climate.py @@ -180,6 +180,8 @@ class SensiboClimate(SensiboDeviceBaseEntity, ClimateEntity): """Representation of a Sensibo device.""" _attr_name = None + _attr_precision = PRECISION_TENTHS + _attr_translation_key = "climate_device" def __init__( self, coordinator: SensiboDataUpdateCoordinator, device_id: str @@ -193,8 +195,6 @@ def __init__( else UnitOfTemperature.FAHRENHEIT ) self._attr_supported_features = self.get_features() - self._attr_precision = PRECISION_TENTHS - self._attr_translation_key = "climate_device" def get_features(self) -> ClimateEntityFeature: """Get supported features.""" diff --git a/homeassistant/components/sensibo/number.py b/homeassistant/components/sensibo/number.py index 94765a17a4d5dc..d4e268ea44d4d3 100644 --- a/homeassistant/components/sensibo/number.py +++ b/homeassistant/components/sensibo/number.py @@ -7,9 +7,13 @@ from pysensibo.model import SensiboDevice -from homeassistant.components.number import NumberEntity, NumberEntityDescription +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EntityCategory +from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -39,8 +43,9 @@ class SensiboNumberEntityDescription( SensiboNumberEntityDescription( key="calibration_temp", translation_key="calibration_temperature", + device_class=NumberDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, remote_key="temperature", - icon="mdi:thermometer", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, native_min_value=-10, @@ -51,8 +56,9 @@ class SensiboNumberEntityDescription( SensiboNumberEntityDescription( key="calibration_hum", translation_key="calibration_humidity", + device_class=NumberDeviceClass.HUMIDITY, + native_unit_of_measurement=PERCENTAGE, remote_key="humidity", - icon="mdi:water", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, native_min_value=-10, diff --git a/homeassistant/components/sensibo/sensor.py b/homeassistant/components/sensibo/sensor.py index 7208902456ea8b..f6d62d79dff3ce 100644 --- a/homeassistant/components/sensibo/sensor.py +++ b/homeassistant/components/sensibo/sensor.py @@ -107,6 +107,7 @@ class SensiboDeviceSensorEntityDescription( SensiboMotionSensorEntityDescription( key="temperature", device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, icon="mdi:thermometer", value_fn=lambda data: data.temperature, @@ -145,6 +146,7 @@ class SensiboDeviceSensorEntityDescription( key="feels_like", translation_key="feels_like", device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, value_fn=lambda data: data.feelslike, extra_fn=None, @@ -154,6 +156,7 @@ class SensiboDeviceSensorEntityDescription( key="climate_react_low", translation_key="climate_react_low", device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, value_fn=lambda data: data.smart_low_temp_threshold, extra_fn=lambda data: data.smart_low_state, @@ -163,6 +166,7 @@ class SensiboDeviceSensorEntityDescription( key="climate_react_high", translation_key="climate_react_high", device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, value_fn=lambda data: data.smart_high_temp_threshold, extra_fn=lambda data: data.smart_high_state, @@ -228,7 +232,7 @@ class SensiboDeviceSensorEntityDescription( key="ethanol", native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, state_class=SensorStateClass.MEASUREMENT, - name="Ethanol", + translation_key="ethanol", value_fn=lambda data: data.etoh, extra_fn=None, ), @@ -299,13 +303,6 @@ def __init__( self.entity_description = entity_description self._attr_unique_id = f"{sensor_id}-{entity_description.key}" - @property - def native_unit_of_measurement(self) -> str | None: - """Add native unit of measurement.""" - if self.entity_description.device_class == SensorDeviceClass.TEMPERATURE: - return UnitOfTemperature.CELSIUS - return self.entity_description.native_unit_of_measurement - @property def native_value(self) -> StateType: """Return value of sensor.""" @@ -333,13 +330,6 @@ def __init__( self.entity_description = entity_description self._attr_unique_id = f"{device_id}-{entity_description.key}" - @property - def native_unit_of_measurement(self) -> str | None: - """Add native unit of measurement.""" - if self.entity_description.device_class == SensorDeviceClass.TEMPERATURE: - return UnitOfTemperature.CELSIUS - return self.entity_description.native_unit_of_measurement - @property def native_value(self) -> StateType | datetime: """Return value of sensor.""" diff --git a/homeassistant/components/sensirion_ble/manifest.json b/homeassistant/components/sensirion_ble/manifest.json index 38f66a88e8e06d..01ccc873f56f34 100644 --- a/homeassistant/components/sensirion_ble/manifest.json +++ b/homeassistant/components/sensirion_ble/manifest.json @@ -16,5 +16,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/sensirion_ble", "iot_class": "local_push", - "requirements": ["sensirion-ble==0.1.0"] + "requirements": ["sensirion-ble==0.1.1"] } diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index b8151256519f9a..4faeca33df58c9 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -13,7 +13,7 @@ from homeassistant.config_entries import ConfigEntry -# pylint: disable=[hass-deprecated-import] +# pylint: disable-next=hass-deprecated-import from homeassistant.const import ( # noqa: F401 ATTR_UNIT_OF_MEASUREMENT, CONF_UNIT_OF_MEASUREMENT, @@ -149,6 +149,8 @@ class SensorEntityDescription(EntityDescription): class SensorEntity(Entity): """Base class for sensor entities.""" + _entity_component_unrecorded_attributes = frozenset({ATTR_OPTIONS}) + entity_description: SensorEntityDescription _attr_device_class: SensorDeviceClass | None _attr_last_reset: datetime | None diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index e5a35187c99d74..2ef1b6854fc0a6 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -30,19 +30,13 @@ UnitOfSoundPressure, UnitOfVolume, ) -from homeassistant.core import HomeAssistant, State, callback, split_entity_id +from homeassistant.core import HomeAssistant, State, split_entity_id from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity import entity_sources from homeassistant.util import dt as dt_util from homeassistant.util.enum import try_parse_enum -from .const import ( - ATTR_LAST_RESET, - ATTR_OPTIONS, - ATTR_STATE_CLASS, - DOMAIN, - SensorStateClass, -) +from .const import ATTR_LAST_RESET, ATTR_STATE_CLASS, DOMAIN, SensorStateClass _LOGGER = logging.getLogger(__name__) @@ -262,8 +256,9 @@ def _normalize_states( def _suggest_report_issue(hass: HomeAssistant, entity_id: str) -> str: """Suggest to report an issue.""" - domain = entity_sources(hass).get(entity_id, {}).get("domain") - custom_component = entity_sources(hass).get(entity_id, {}).get("custom_component") + entity_info = entity_sources(hass).get(entity_id) + domain = entity_info["domain"] if entity_info else None + custom_component = entity_info["custom_component"] if entity_info else None report_issue = "" if custom_component: report_issue = "report it to the custom integration author." @@ -296,7 +291,8 @@ def warn_dip( hass.data[WARN_DIP] = set() if entity_id not in hass.data[WARN_DIP]: hass.data[WARN_DIP].add(entity_id) - domain = entity_sources(hass).get(entity_id, {}).get("domain") + entity_info = entity_sources(hass).get(entity_id) + domain = entity_info["domain"] if entity_info else None if domain in ["energy", "growatt_server", "solaredge"]: return _LOGGER.warning( @@ -320,7 +316,8 @@ def warn_negative(hass: HomeAssistant, entity_id: str, state: State) -> None: hass.data[WARN_NEGATIVE] = set() if entity_id not in hass.data[WARN_NEGATIVE]: hass.data[WARN_NEGATIVE].add(entity_id) - domain = entity_sources(hass).get(entity_id, {}).get("domain") + entity_info = entity_sources(hass).get(entity_id) + domain = entity_info["domain"] if entity_info else None _LOGGER.warning( ( "Entity %s %shas state class total_increasing, but its state is " @@ -787,9 +784,3 @@ def validate_statistics( ) return validation_result - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude attributes from being recorded in the database.""" - return {ATTR_OPTIONS} diff --git a/homeassistant/components/sentry/manifest.json b/homeassistant/components/sentry/manifest.json index 149e503d0f8e49..fa1044414bb9b0 100644 --- a/homeassistant/components/sentry/manifest.json +++ b/homeassistant/components/sentry/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/sentry", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["sentry-sdk==1.28.1"] + "requirements": ["sentry-sdk==1.31.0"] } diff --git a/homeassistant/components/sharkiq/vacuum.py b/homeassistant/components/sharkiq/vacuum.py index 8c6c4a9197a0d1..9510b7d3f66029 100644 --- a/homeassistant/components/sharkiq/vacuum.py +++ b/homeassistant/components/sharkiq/vacuum.py @@ -88,7 +88,13 @@ def __init__( super().__init__(coordinator) self.sharkiq = sharkiq self._attr_unique_id = sharkiq.serial_number - self._serial_number = sharkiq.serial_number + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, sharkiq.serial_number)}, + manufacturer=SHARK, + model=self.model, + name=sharkiq.name, + sw_version=sharkiq.get_property_value(Properties.ROBOT_FIRMWARE_VERSION), + ) def clean_spot(self, **kwargs: Any) -> None: """Clean a spot. Not yet implemented.""" @@ -106,7 +112,7 @@ def send_command( @property def is_online(self) -> bool: """Tell us if the device is online.""" - return self.coordinator.device_is_online(self._serial_number) + return self.coordinator.device_is_online(self.sharkiq.serial_number) @property def model(self) -> str: @@ -115,19 +121,6 @@ def model(self) -> str: return self.sharkiq.vac_model_number return self.sharkiq.oem_model_number - @property - def device_info(self) -> DeviceInfo: - """Device info dictionary.""" - return DeviceInfo( - identifiers={(DOMAIN, self._serial_number)}, - manufacturer=SHARK, - model=self.model, - name=self.sharkiq.name, - sw_version=self.sharkiq.get_property_value( - Properties.ROBOT_FIRMWARE_VERSION - ), - ) - @property def error_code(self) -> int | None: """Return the last observed error code (or None).""" diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index 09d9e3655f080a..29a0506fcc0f88 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -73,6 +73,7 @@ Platform.BINARY_SENSOR, Platform.BUTTON, Platform.COVER, + Platform.EVENT, Platform.LIGHT, Platform.SENSOR, Platform.SWITCH, diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index 33b4caa5034aaa..0275b8052085b3 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -181,3 +181,8 @@ class BLEScannerMode(StrEnum): NOT_CALIBRATED_ISSUE_ID = "not_calibrated_{unique}" GAS_VALVE_OPEN_STATES = ("opening", "opened") + +OTA_BEGIN = "ota_begin" +OTA_ERROR = "ota_error" +OTA_PROGRESS = "ota_progress" +OTA_SUCCESS = "ota_success" diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index d645b09799f64c..c19aac93dabca9 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -44,6 +44,10 @@ LOGGER, MAX_PUSH_UPDATE_FAILURES, MODELS_SUPPORTING_LIGHT_EFFECTS, + OTA_BEGIN, + OTA_ERROR, + OTA_PROGRESS, + OTA_SUCCESS, PUSH_UPDATE_ISSUE_ID, REST_SENSORS_UPDATE_INTERVAL, RPC_INPUTS_EVENTS_TYPES, @@ -384,6 +388,8 @@ def __init__( self._disconnected_callbacks: list[CALLBACK_TYPE] = [] self._connection_lock = asyncio.Lock() self._event_listeners: list[Callable[[dict[str, Any]], None]] = [] + self._ota_event_listeners: list[Callable[[dict[str, Any]], None]] = [] + self._input_event_listeners: list[Callable[[dict[str, Any]], None]] = [] entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._handle_ha_stop) @@ -408,6 +414,32 @@ def update_sleep_period(self) -> bool: return True + @callback + def async_subscribe_ota_events( + self, ota_event_callback: Callable[[dict[str, Any]], None] + ) -> CALLBACK_TYPE: + """Subscribe to OTA events.""" + + def _unsubscribe() -> None: + self._ota_event_listeners.remove(ota_event_callback) + + self._ota_event_listeners.append(ota_event_callback) + + return _unsubscribe + + @callback + def async_subscribe_input_events( + self, input_event_callback: Callable[[dict[str, Any]], None] + ) -> CALLBACK_TYPE: + """Subscribe to input events.""" + + def _unsubscribe() -> None: + self._input_event_listeners.remove(input_event_callback) + + self._input_event_listeners.append(input_event_callback) + + return _unsubscribe + @callback def async_subscribe_events( self, event_callback: Callable[[dict[str, Any]], None] @@ -451,6 +483,8 @@ def _async_device_event_handler(self, event_data: dict[str, Any]) -> None: ) self.hass.async_create_task(self._debounced_reload.async_call()) elif event_type in RPC_INPUTS_EVENTS_TYPES: + for event_callback in self._input_event_listeners: + event_callback(event) self.hass.bus.async_fire( EVENT_SHELLY_CLICK, { @@ -461,6 +495,9 @@ def _async_device_event_handler(self, event_data: dict[str, Any]) -> None: ATTR_GENERATION: 2, }, ) + elif event_type in (OTA_BEGIN, OTA_ERROR, OTA_PROGRESS, OTA_SUCCESS): + for event_callback in self._ota_event_listeners: + event_callback(event) async def _async_update_data(self) -> None: """Fetch data.""" diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py index 1dc7573b738294..5afa5f8b7270a2 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -332,6 +332,7 @@ def __init__(self, coordinator: ShellyBlockCoordinator, block: Block) -> None: ) self._attr_unique_id = f"{coordinator.mac}-{block.description}" + # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: """When entity is added to HASS.""" self.async_on_remove(self.coordinator.async_add_listener(self._update_callback)) @@ -375,6 +376,7 @@ def status(self) -> dict: """Device status by entity key.""" return cast(dict, self.coordinator.device.status[self.key]) + # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: """When entity is added to HASS.""" self.async_on_remove(self.coordinator.async_add_listener(self._update_callback)) @@ -551,7 +553,7 @@ def available(self) -> bool: class ShellySleepingBlockAttributeEntity(ShellyBlockAttributeEntity): """Represent a shelly sleeping block attribute entity.""" - # pylint: disable=super-init-not-called + # pylint: disable-next=super-init-not-called def __init__( self, coordinator: ShellyBlockCoordinator, @@ -625,7 +627,7 @@ class ShellySleepingRpcAttributeEntity(ShellyRpcAttributeEntity): entity_description: RpcEntityDescription - # pylint: disable=super-init-not-called + # pylint: disable-next=super-init-not-called def __init__( self, coordinator: ShellyRpcCoordinator, diff --git a/homeassistant/components/shelly/event.py b/homeassistant/components/shelly/event.py new file mode 100644 index 00000000000000..2abedf3cf9a7f1 --- /dev/null +++ b/homeassistant/components/shelly/event.py @@ -0,0 +1,108 @@ +"""Event for Shelly.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any, Final + +from homeassistant.components.event import ( + DOMAIN as EVENT_DOMAIN, + EventDeviceClass, + EventEntity, + EventEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import RPC_INPUTS_EVENTS_TYPES +from .coordinator import ShellyRpcCoordinator, get_entry_data +from .utils import ( + async_remove_shelly_entity, + get_device_entry_gen, + get_rpc_input_name, + get_rpc_key_instances, + is_rpc_momentary_input, +) + + +@dataclass +class ShellyEventDescription(EventEntityDescription): + """Class to describe Shelly event.""" + + removal_condition: Callable[[dict, dict, str], bool] | None = None + + +RPC_EVENT: Final = ShellyEventDescription( + key="input", + translation_key="input", + device_class=EventDeviceClass.BUTTON, + event_types=list(RPC_INPUTS_EVENTS_TYPES), + removal_condition=lambda config, status, key: not is_rpc_momentary_input( + config, status, key + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up sensors for device.""" + if get_device_entry_gen(config_entry) == 2: + coordinator = get_entry_data(hass)[config_entry.entry_id].rpc + assert coordinator + + entities = [] + key_instances = get_rpc_key_instances(coordinator.device.status, RPC_EVENT.key) + + for key in key_instances: + if RPC_EVENT.removal_condition and RPC_EVENT.removal_condition( + coordinator.device.config, coordinator.device.status, key + ): + unique_id = f"{coordinator.mac}-{key}" + async_remove_shelly_entity(hass, EVENT_DOMAIN, unique_id) + else: + entities.append(ShellyRpcEvent(coordinator, key, RPC_EVENT)) + + async_add_entities(entities) + + +class ShellyRpcEvent(CoordinatorEntity[ShellyRpcCoordinator], EventEntity): + """Represent RPC event entity.""" + + _attr_should_poll = False + entity_description: ShellyEventDescription + + def __init__( + self, + coordinator: ShellyRpcCoordinator, + key: str, + description: ShellyEventDescription, + ) -> None: + """Initialize Shelly entity.""" + super().__init__(coordinator) + self.input_index = int(key.split(":")[-1]) + self._attr_device_info = DeviceInfo( + connections={(CONNECTION_NETWORK_MAC, coordinator.mac)} + ) + self._attr_unique_id = f"{coordinator.mac}-{key}" + self._attr_name = get_rpc_input_name(coordinator.device, key) + self.entity_description = description + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self.async_on_remove( + self.coordinator.async_subscribe_input_events(self._async_handle_event) + ) + + @callback + def _async_handle_event(self, event: dict[str, Any]) -> None: + """Handle the demo button event.""" + if event["id"] == self.input_index: + self._trigger_event(event["event"]) + self.async_write_ha_state() diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index abcca888005425..99ccd9ab2ff289 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -363,6 +363,14 @@ class RestSensorDescription(RestEntityDescription, SensorEntityDescription): device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), + "power_em1": RpcSensorDescription( + key="em1", + sub_key="act_power", + name="Power", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), "power_pm1": RpcSensorDescription( key="pm1", sub_key="apower", @@ -427,6 +435,14 @@ class RestSensorDescription(RestEntityDescription, SensorEntityDescription): device_class=SensorDeviceClass.APPARENT_POWER, state_class=SensorStateClass.MEASUREMENT, ), + "aprt_power_em1": RpcSensorDescription( + key="em1", + sub_key="aprt_power", + name="Apparent power", + native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, + device_class=SensorDeviceClass.APPARENT_POWER, + state_class=SensorStateClass.MEASUREMENT, + ), "total_aprt_power": RpcSensorDescription( key="em", sub_key="total_aprt_power", @@ -435,6 +451,13 @@ class RestSensorDescription(RestEntityDescription, SensorEntityDescription): device_class=SensorDeviceClass.APPARENT_POWER, state_class=SensorStateClass.MEASUREMENT, ), + "pf_em1": RpcSensorDescription( + key="em1", + sub_key="pf", + name="Power factor", + device_class=SensorDeviceClass.POWER_FACTOR, + state_class=SensorStateClass.MEASUREMENT, + ), "a_pf": RpcSensorDescription( key="em", sub_key="a_pf", @@ -467,6 +490,17 @@ class RestSensorDescription(RestEntityDescription, SensorEntityDescription): state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), + "voltage_em1": RpcSensorDescription( + key="em1", + sub_key="voltage", + name="Voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + value=lambda status, _: None if status is None else float(status), + suggested_display_precision=1, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), "voltage_pm1": RpcSensorDescription( key="pm1", sub_key="voltage", @@ -515,6 +549,16 @@ class RestSensorDescription(RestEntityDescription, SensorEntityDescription): state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), + "current_em1": RpcSensorDescription( + key="em1", + sub_key="current", + name="Current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + value=lambda status, _: None if status is None else float(status), + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), "current_pm1": RpcSensorDescription( key="pm1", sub_key="current", @@ -605,6 +649,18 @@ class RestSensorDescription(RestEntityDescription, SensorEntityDescription): device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), + "total_act_energy": RpcSensorDescription( + key="em1data", + sub_key="total_act_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), + suggested_display_precision=2, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, + ), "a_total_act_energy": RpcSensorDescription( key="emdata", sub_key="a_total_act_energy", @@ -652,6 +708,18 @@ class RestSensorDescription(RestEntityDescription, SensorEntityDescription): device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), + "total_act_ret_energy": RpcSensorDescription( + key="em1data", + sub_key="total_act_ret_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), + suggested_display_precision=2, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, + ), "a_total_act_ret_energy": RpcSensorDescription( key="emdata", sub_key="a_total_act_ret_energy", @@ -698,6 +766,16 @@ class RestSensorDescription(RestEntityDescription, SensorEntityDescription): state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), + "freq_em1": RpcSensorDescription( + key="em1", + sub_key="freq", + 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, + ), "freq_pm1": RpcSensorDescription( key="pm1", sub_key="freq", diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index 043ff419742d2b..d2e72ee81da398 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -98,6 +98,22 @@ } } }, + "event": { + "input": { + "state_attributes": { + "event_type": { + "state": { + "btn_down": "Button down", + "btn_up": "Button up", + "double_push": "Double push", + "long_push": "Long push", + "single_push": "Single push", + "triple_push": "Triple push" + } + } + } + } + }, "sensor": { "operation": { "state": { @@ -120,7 +136,7 @@ "valve_status": { "state": { "checking": "Checking", - "closed": "Closed", + "closed": "[%key:common::state::closed%]", "closing": "Closing", "failure": "Failure", "opened": "Opened", diff --git a/homeassistant/components/shelly/update.py b/homeassistant/components/shelly/update.py index 3b2096f0c1a997..d4528f552885d0 100644 --- a/homeassistant/components/shelly/update.py +++ b/homeassistant/components/shelly/update.py @@ -18,12 +18,12 @@ ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -from .const import CONF_SLEEP_PERIOD +from .const import CONF_SLEEP_PERIOD, OTA_BEGIN, OTA_ERROR, OTA_PROGRESS, OTA_SUCCESS from .coordinator import ShellyBlockCoordinator, ShellyRpcCoordinator from .entity import ( RestEntityDescription, @@ -229,7 +229,28 @@ def __init__( ) -> None: """Initialize update entity.""" super().__init__(coordinator, key, attribute, description) - self._in_progress_old_version: str | None = None + self._ota_in_progress: bool = False + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self.async_on_remove( + self.coordinator.async_subscribe_ota_events(self._ota_progress_callback) + ) + + @callback + def _ota_progress_callback(self, event: dict[str, Any]) -> None: + """Handle device OTA progress.""" + if self._ota_in_progress: + event_type = event["event"] + if event_type == OTA_BEGIN: + self._attr_in_progress = 0 + elif event_type == OTA_PROGRESS: + self._attr_in_progress = event["progress_percent"] + elif event_type in (OTA_ERROR, OTA_SUCCESS): + self._attr_in_progress = False + self._ota_in_progress = False + self.async_write_ha_state() @property def installed_version(self) -> str | None: @@ -245,16 +266,10 @@ def latest_version(self) -> str | None: return self.installed_version - @property - def in_progress(self) -> bool: - """Update installation in progress.""" - return self._in_progress_old_version == self.installed_version - async def async_install( self, version: str | None, backup: bool, **kwargs: Any ) -> None: """Install the latest firmware version.""" - self._in_progress_old_version = self.installed_version beta = self.entity_description.beta update_data = self.coordinator.device.status["sys"]["available_updates"] LOGGER.debug("OTA update service - update_data: %s", update_data) @@ -280,6 +295,7 @@ async def async_install( except InvalidAuthError: self.coordinator.entry.async_start_reauth(self.hass) else: + self._ota_in_progress = True LOGGER.debug("OTA update call successful") diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index a66b77ed94b61d..5633f674168717 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -285,9 +285,20 @@ def get_model_name(info: dict[str, Any]) -> str: return cast(str, MODEL_NAMES.get(info["type"], info["type"])) +def get_rpc_input_name(device: RpcDevice, key: str) -> str: + """Get input name based from the device configuration.""" + input_config = device.config[key] + + if input_name := input_config.get("name"): + return f"{device.name} {input_name}" + + return f"{device.name} {key.replace(':', ' ').capitalize()}" + + def get_rpc_channel_name(device: RpcDevice, key: str) -> str: """Get name based on device and channel name.""" key = key.replace("emdata", "em") + key = key.replace("em1data", "em1") if device.config.get("switch:0"): key = key.replace("input", "switch") device_name = device.name @@ -298,6 +309,8 @@ def get_rpc_channel_name(device: RpcDevice, key: str) -> str: if entity_name is None: if key.startswith(("input:", "light:", "switch:")): return f"{device_name} {key.replace(':', '_')}" + if key.startswith("em1"): + return f"{device_name} EM{key.split(':')[-1]}" return device_name return entity_name diff --git a/homeassistant/components/simplepush/config_flow.py b/homeassistant/components/simplepush/config_flow.py index 702be4391e4e40..d87f6fa1913190 100644 --- a/homeassistant/components/simplepush/config_flow.py +++ b/homeassistant/components/simplepush/config_flow.py @@ -20,7 +20,7 @@ def validate_input(entry: dict[str, str]) -> dict[str, str] | None: send( key=entry[CONF_DEVICE_KEY], password=entry[CONF_PASSWORD], - salt=entry[CONF_PASSWORD], + salt=entry[CONF_SALT], title="HA test", message="Message delivered successfully", ) diff --git a/homeassistant/components/simplepush/manifest.json b/homeassistant/components/simplepush/manifest.json index 25f53a9617ca2a..5b792072f4479d 100644 --- a/homeassistant/components/simplepush/manifest.json +++ b/homeassistant/components/simplepush/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/simplepush", "iot_class": "cloud_polling", "loggers": ["simplepush"], - "requirements": ["simplepush==2.1.1"] + "requirements": ["simplepush==2.2.3"] } diff --git a/homeassistant/components/siren/__init__.py b/homeassistant/components/siren/__init__.py index a8907ba3b687a7..ac02201b92862c 100644 --- a/homeassistant/components/siren/__init__.py +++ b/homeassistant/components/siren/__init__.py @@ -159,6 +159,8 @@ class SirenEntityDescription(ToggleEntityDescription): class SirenEntity(ToggleEntity): """Representation of a siren device.""" + _entity_component_unrecorded_attributes = frozenset({ATTR_AVAILABLE_TONES}) + entity_description: SirenEntityDescription _attr_available_tones: list[int | str] | dict[int, str] | None _attr_supported_features: SirenEntityFeature = SirenEntityFeature(0) diff --git a/homeassistant/components/siren/recorder.py b/homeassistant/components/siren/recorder.py deleted file mode 100644 index 3daf4fc52b21fc..00000000000000 --- a/homeassistant/components/siren/recorder.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.core import HomeAssistant, callback - -from . import ATTR_AVAILABLE_TONES - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude static attributes from being recorded in the database.""" - return {ATTR_AVAILABLE_TONES} diff --git a/homeassistant/components/slack/const.py b/homeassistant/components/slack/const.py index ec0993e290b183..ccc1fbb664366c 100644 --- a/homeassistant/components/slack/const.py +++ b/homeassistant/components/slack/const.py @@ -10,6 +10,7 @@ ATTR_URL = "url" ATTR_USERNAME = "username" ATTR_USER_ID = "user_id" +ATTR_THREAD_TS = "thread_ts" CONF_DEFAULT_CHANNEL = "default_channel" diff --git a/homeassistant/components/slack/manifest.json b/homeassistant/components/slack/manifest.json index 2bd3476cbbe40f..1b35db6f061dee 100644 --- a/homeassistant/components/slack/manifest.json +++ b/homeassistant/components/slack/manifest.json @@ -1,7 +1,7 @@ { "domain": "slack", "name": "Slack", - "codeowners": ["@tkdrob"], + "codeowners": ["@tkdrob", "@fletcherau"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/slack", "integration_type": "service", diff --git a/homeassistant/components/slack/notify.py b/homeassistant/components/slack/notify.py index 498eddffa3d88b..deba0796750b62 100644 --- a/homeassistant/components/slack/notify.py +++ b/homeassistant/components/slack/notify.py @@ -30,6 +30,7 @@ ATTR_FILE, ATTR_PASSWORD, ATTR_PATH, + ATTR_THREAD_TS, ATTR_URL, ATTR_USERNAME, CONF_DEFAULT_CHANNEL, @@ -50,7 +51,10 @@ ) DATA_FILE_SCHEMA = vol.Schema( - {vol.Required(ATTR_FILE): vol.Any(FILE_PATH_SCHEMA, FILE_URL_SCHEMA)} + { + vol.Required(ATTR_FILE): vol.Any(FILE_PATH_SCHEMA, FILE_URL_SCHEMA), + vol.Optional(ATTR_THREAD_TS): cv.string, + } ) DATA_TEXT_ONLY_SCHEMA = vol.Schema( @@ -59,6 +63,7 @@ vol.Optional(ATTR_ICON): cv.string, vol.Optional(ATTR_BLOCKS): list, vol.Optional(ATTR_BLOCKS_TEMPLATE): list, + vol.Optional(ATTR_THREAD_TS): cv.string, } ) @@ -73,7 +78,7 @@ class AuthDictT(TypedDict, total=False): auth: BasicAuth -class FormDataT(TypedDict): +class FormDataT(TypedDict, total=False): """Type for form data, file upload.""" channels: str @@ -81,6 +86,7 @@ class FormDataT(TypedDict): initial_comment: str title: str token: str + thread_ts: str # Optional key class MessageT(TypedDict, total=False): @@ -92,6 +98,7 @@ class MessageT(TypedDict, total=False): icon_url: str # Optional key icon_emoji: str # Optional key blocks: list[Any] # Optional key + thread_ts: str # Optional key async def async_get_service( @@ -142,6 +149,7 @@ async def _async_send_local_file_message( targets: list[str], message: str, title: str | None, + thread_ts: str | None, ) -> None: """Upload a local file (with message) to Slack.""" if not self._hass.config.is_allowed_path(path): @@ -158,6 +166,7 @@ async def _async_send_local_file_message( filename=filename, initial_comment=message, title=title or filename, + thread_ts=thread_ts, ) except (SlackApiError, ClientError) as err: _LOGGER.error("Error while uploading file-based message: %r", err) @@ -168,6 +177,7 @@ async def _async_send_remote_file_message( targets: list[str], message: str, title: str | None, + thread_ts: str | None, *, username: str | None = None, password: str | None = None, @@ -205,6 +215,9 @@ async def _async_send_remote_file_message( "token": self._client.token, } + if thread_ts: + form_data["thread_ts"] = thread_ts + data = FormData(form_data, charset="utf-8") data.add_field("file", resp.content, filename=filename) @@ -218,6 +231,7 @@ async def _async_send_text_only_message( targets: list[str], message: str, title: str | None, + thread_ts: str | None, *, username: str | None = None, icon: str | None = None, @@ -238,6 +252,9 @@ async def _async_send_text_only_message( if blocks: message_dict["blocks"] = blocks + if thread_ts: + message_dict["thread_ts"] = thread_ts + tasks = { target: self._client.chat_postMessage(**message_dict, channel=target) for target in targets @@ -286,6 +303,7 @@ async def async_send_message(self, message: str, **kwargs: Any) -> None: title, username=data.get(ATTR_USERNAME, self._config.get(ATTR_USERNAME)), icon=data.get(ATTR_ICON, self._config.get(ATTR_ICON)), + thread_ts=data.get(ATTR_THREAD_TS), blocks=blocks, ) @@ -296,11 +314,16 @@ async def async_send_message(self, message: str, **kwargs: Any) -> None: targets, message, title, + thread_ts=data.get(ATTR_THREAD_TS), username=data[ATTR_FILE].get(ATTR_USERNAME), password=data[ATTR_FILE].get(ATTR_PASSWORD), ) # Message Type 3: A message that uploads a local file return await self._async_send_local_file_message( - data[ATTR_FILE][ATTR_PATH], targets, message, title + data[ATTR_FILE][ATTR_PATH], + targets, + message, + title, + thread_ts=data.get(ATTR_THREAD_TS), ) diff --git a/homeassistant/components/sma/sensor.py b/homeassistant/components/sma/sensor.py index dbcc1931e58327..11ed720b51c1ec 100644 --- a/homeassistant/components/sma/sensor.py +++ b/homeassistant/components/sma/sensor.py @@ -8,10 +8,22 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, + SensorEntityDescription, SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import UnitOfEnergy, UnitOfPower +from homeassistant.const import ( + PERCENTAGE, + POWER_VOLT_AMPERE_REACTIVE, + EntityCategory, + UnitOfApparentPower, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfEnergy, + UnitOfFrequency, + UnitOfPower, + UnitOfTemperature, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -23,6 +35,762 @@ from .const import DOMAIN, PYSMA_COORDINATOR, PYSMA_DEVICE_INFO, PYSMA_SENSORS +SENSOR_ENTITIES: dict[str, SensorEntityDescription] = { + "status": SensorEntityDescription( + key="status", + name="Status", + entity_category=EntityCategory.DIAGNOSTIC, + ), + "operating_status_general": SensorEntityDescription( + key="operating_status_general", + name="Operating Status General", + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + ), + "inverter_condition": SensorEntityDescription( + key="inverter_condition", + name="Inverter Condition", + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + ), + "inverter_system_init": SensorEntityDescription( + key="inverter_system_init", + name="Inverter System Init", + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + ), + "grid_connection_status": SensorEntityDescription( + key="grid_connection_status", + name="Grid Connection Status", + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + ), + "grid_relay_status": SensorEntityDescription( + key="grid_relay_status", + name="Grid Relay Status", + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + ), + "pv_power_a": SensorEntityDescription( + key="pv_power_a", + name="PV Power A", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + ), + "pv_power_b": SensorEntityDescription( + key="pv_power_b", + name="PV Power B", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + ), + "pv_power_c": SensorEntityDescription( + key="pv_power_c", + name="PV Power C", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + entity_registry_enabled_default=False, + ), + "pv_voltage_a": SensorEntityDescription( + key="pv_voltage_a", + name="PV Voltage A", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLTAGE, + entity_registry_enabled_default=False, + ), + "pv_voltage_b": SensorEntityDescription( + key="pv_voltage_b", + name="PV Voltage B", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLTAGE, + entity_registry_enabled_default=False, + ), + "pv_voltage_c": SensorEntityDescription( + key="pv_voltage_c", + name="PV Voltage C", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLTAGE, + entity_registry_enabled_default=False, + ), + "pv_current_a": SensorEntityDescription( + key="pv_current_a", + name="PV Current A", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + ), + "pv_current_b": SensorEntityDescription( + key="pv_current_b", + name="PV Current B", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + ), + "pv_current_c": SensorEntityDescription( + key="pv_current_c", + name="PV Current C", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + entity_registry_enabled_default=False, + ), + "insulation_residual_current": SensorEntityDescription( + key="insulation_residual_current", + name="Insulation Residual Current", + native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + entity_registry_enabled_default=False, + ), + "grid_power": SensorEntityDescription( + key="grid_power", + name="Grid Power", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + ), + "frequency": SensorEntityDescription( + key="frequency", + name="Frequency", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.FREQUENCY, + entity_registry_enabled_default=False, + ), + "power_l1": SensorEntityDescription( + key="power_l1", + name="Power L1", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + entity_registry_enabled_default=False, + ), + "power_l2": SensorEntityDescription( + key="power_l2", + name="Power L2", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + entity_registry_enabled_default=False, + ), + "power_l3": SensorEntityDescription( + key="power_l3", + name="Power L3", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + entity_registry_enabled_default=False, + ), + "grid_reactive_power": SensorEntityDescription( + key="grid_reactive_power", + name="Grid Reactive Power", + native_unit_of_measurement=POWER_VOLT_AMPERE_REACTIVE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.REACTIVE_POWER, + entity_registry_enabled_default=False, + ), + "grid_reactive_power_l1": SensorEntityDescription( + key="grid_reactive_power_l1", + name="Grid Reactive Power L1", + native_unit_of_measurement=POWER_VOLT_AMPERE_REACTIVE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.REACTIVE_POWER, + entity_registry_enabled_default=False, + ), + "grid_reactive_power_l2": SensorEntityDescription( + key="grid_reactive_power_l2", + name="Grid Reactive Power L2", + native_unit_of_measurement=POWER_VOLT_AMPERE_REACTIVE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.REACTIVE_POWER, + entity_registry_enabled_default=False, + ), + "grid_reactive_power_l3": SensorEntityDescription( + key="grid_reactive_power_l3", + name="Grid Reactive Power L3", + native_unit_of_measurement=POWER_VOLT_AMPERE_REACTIVE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.REACTIVE_POWER, + entity_registry_enabled_default=False, + ), + "grid_apparent_power": SensorEntityDescription( + key="grid_apparent_power", + name="Grid Apparent Power", + native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.APPARENT_POWER, + entity_registry_enabled_default=False, + ), + "grid_apparent_power_l1": SensorEntityDescription( + key="grid_apparent_power_l1", + name="Grid Apparent Power L1", + native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.APPARENT_POWER, + entity_registry_enabled_default=False, + ), + "grid_apparent_power_l2": SensorEntityDescription( + key="grid_apparent_power_l2", + name="Grid Apparent Power L2", + native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.APPARENT_POWER, + entity_registry_enabled_default=False, + ), + "grid_apparent_power_l3": SensorEntityDescription( + key="grid_apparent_power_l3", + name="Grid Apparent Power L3", + native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.APPARENT_POWER, + entity_registry_enabled_default=False, + ), + "grid_power_factor": SensorEntityDescription( + key="grid_power_factor", + name="Grid Power Factor", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER_FACTOR, + entity_registry_enabled_default=False, + ), + "grid_power_factor_excitation": SensorEntityDescription( + key="grid_power_factor_excitation", + name="Grid Power Factor Excitation", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER_FACTOR, + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + ), + "current_l1": SensorEntityDescription( + key="current_l1", + name="Current L1", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + entity_registry_enabled_default=False, + ), + "current_l2": SensorEntityDescription( + key="current_l2", + name="Current L2", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + entity_registry_enabled_default=False, + ), + "current_l3": SensorEntityDescription( + key="current_l3", + name="Current L3", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + entity_registry_enabled_default=False, + ), + "current_total": SensorEntityDescription( + key="current_total", + name="Current Total", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + ), + "voltage_l1": SensorEntityDescription( + key="voltage_l1", + name="Voltage L1", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLTAGE, + entity_registry_enabled_default=False, + ), + "voltage_l2": SensorEntityDescription( + key="voltage_l2", + name="Voltage L2", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLTAGE, + entity_registry_enabled_default=False, + ), + "voltage_l3": SensorEntityDescription( + key="voltage_l3", + name="Voltage L3", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLTAGE, + entity_registry_enabled_default=False, + ), + "total_yield": SensorEntityDescription( + key="total_yield", + name="Total Yield", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + ), + "daily_yield": SensorEntityDescription( + key="daily_yield", + name="Daily Yield", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + ), + "metering_power_supplied": SensorEntityDescription( + key="metering_power_supplied", + name="Metering Power Supplied", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + ), + "metering_power_absorbed": SensorEntityDescription( + key="metering_power_absorbed", + name="Metering Power Absorbed", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + ), + "metering_frequency": SensorEntityDescription( + key="metering_frequency", + name="Metering Frequency", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.FREQUENCY, + ), + "metering_total_yield": SensorEntityDescription( + key="metering_total_yield", + name="Metering Total Yield", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + ), + "metering_total_absorbed": SensorEntityDescription( + key="metering_total_absorbed", + name="Metering Total Absorbed", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + ), + "metering_current_l1": SensorEntityDescription( + key="metering_current_l1", + name="Metering Current L1", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + ), + "metering_current_l2": SensorEntityDescription( + key="metering_current_l2", + name="Metering Current L2", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + ), + "metering_current_l3": SensorEntityDescription( + key="metering_current_l3", + name="Metering Current L3", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + ), + "metering_voltage_l1": SensorEntityDescription( + key="metering_voltage_l1", + name="Metering Voltage L1", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLTAGE, + entity_registry_enabled_default=False, + ), + "metering_voltage_l2": SensorEntityDescription( + key="metering_voltage_l2", + name="Metering Voltage L2", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLTAGE, + entity_registry_enabled_default=False, + ), + "metering_voltage_l3": SensorEntityDescription( + key="metering_voltage_l3", + name="Metering Voltage L3", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLTAGE, + entity_registry_enabled_default=False, + ), + "metering_active_power_feed_l1": SensorEntityDescription( + key="metering_active_power_feed_l1", + name="Metering Active Power Feed L1", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + ), + "metering_active_power_feed_l2": SensorEntityDescription( + key="metering_active_power_feed_l2", + name="Metering Active Power Feed L2", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + ), + "metering_active_power_feed_l3": SensorEntityDescription( + key="metering_active_power_feed_l3", + name="Metering Active Power Feed L3", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + ), + "metering_active_power_draw_l1": SensorEntityDescription( + key="metering_active_power_draw_l1", + name="Metering Active Power Draw L1", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + ), + "metering_active_power_draw_l2": SensorEntityDescription( + key="metering_active_power_draw_l2", + name="Metering Active Power Draw L2", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + ), + "metering_active_power_draw_l3": SensorEntityDescription( + key="metering_active_power_draw_l3", + name="Metering Active Power Draw L3", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + ), + "metering_current_consumption": SensorEntityDescription( + key="metering_current_consumption", + name="Metering Current Consumption", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + entity_registry_enabled_default=False, + ), + "metering_total_consumption": SensorEntityDescription( + key="metering_total_consumption", + name="Metering Total Consumption", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + entity_registry_enabled_default=False, + ), + "pv_gen_meter": SensorEntityDescription( + key="pv_gen_meter", + name="PV Gen Meter", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + ), + "optimizer_power": SensorEntityDescription( + key="optimizer_power", + name="Optimizer Power", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + ), + "optimizer_current": SensorEntityDescription( + key="optimizer_current", + name="Optimizer Current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + entity_registry_enabled_default=False, + ), + "optimizer_voltage": SensorEntityDescription( + key="optimizer_voltage", + name="Optimizer Voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLTAGE, + entity_registry_enabled_default=False, + ), + "optimizer_temp": SensorEntityDescription( + key="optimizer_temp", + name="Optimizer Temp", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.TEMPERATURE, + entity_registry_enabled_default=False, + ), + "battery_soc_total": SensorEntityDescription( + key="battery_soc_total", + name="Battery SOC Total", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.BATTERY, + ), + "battery_soc_a": SensorEntityDescription( + key="battery_soc_a", + name="Battery SOC A", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.BATTERY, + entity_registry_enabled_default=False, + ), + "battery_soc_b": SensorEntityDescription( + key="battery_soc_b", + name="Battery SOC B", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.BATTERY, + entity_registry_enabled_default=False, + ), + "battery_soc_c": SensorEntityDescription( + key="battery_soc_c", + name="Battery SOC C", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.BATTERY, + entity_registry_enabled_default=False, + ), + "battery_voltage_a": SensorEntityDescription( + key="battery_voltage_a", + name="Battery Voltage A", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLTAGE, + entity_registry_enabled_default=False, + ), + "battery_voltage_b": SensorEntityDescription( + key="battery_voltage_b", + name="Battery Voltage B", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLTAGE, + entity_registry_enabled_default=False, + ), + "battery_voltage_c": SensorEntityDescription( + key="battery_voltage_c", + name="Battery Voltage C", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLTAGE, + entity_registry_enabled_default=False, + ), + "battery_current_a": SensorEntityDescription( + key="battery_current_a", + name="Battery Current A", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + ), + "battery_current_b": SensorEntityDescription( + key="battery_current_b", + name="Battery Current B", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + ), + "battery_current_c": SensorEntityDescription( + key="battery_current_c", + name="Battery Current C", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + ), + "battery_temp_a": SensorEntityDescription( + key="battery_temp_a", + name="Battery Temp A", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.TEMPERATURE, + ), + "battery_temp_b": SensorEntityDescription( + key="battery_temp_b", + name="Battery Temp B", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.TEMPERATURE, + ), + "battery_temp_c": SensorEntityDescription( + key="battery_temp_c", + name="Battery Temp C", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.TEMPERATURE, + ), + "battery_status_operating_mode": SensorEntityDescription( + key="battery_status_operating_mode", + name="Battery Status Operating Mode", + ), + "battery_capacity_total": SensorEntityDescription( + key="battery_capacity_total", + name="Battery Capacity Total", + native_unit_of_measurement=PERCENTAGE, + ), + "battery_capacity_a": SensorEntityDescription( + key="battery_capacity_a", + name="Battery Capacity A", + native_unit_of_measurement=PERCENTAGE, + entity_registry_enabled_default=False, + ), + "battery_capacity_b": SensorEntityDescription( + key="battery_capacity_b", + name="Battery Capacity B", + native_unit_of_measurement=PERCENTAGE, + entity_registry_enabled_default=False, + ), + "battery_capacity_c": SensorEntityDescription( + key="battery_capacity_c", + name="Battery Capacity C", + native_unit_of_measurement=PERCENTAGE, + entity_registry_enabled_default=False, + ), + "battery_charging_voltage_a": SensorEntityDescription( + key="battery_charging_voltage_a", + name="Battery Charging Voltage A", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLTAGE, + entity_registry_enabled_default=False, + ), + "battery_charging_voltage_b": SensorEntityDescription( + key="battery_charging_voltage_b", + name="Battery Charging Voltage B", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLTAGE, + entity_registry_enabled_default=False, + ), + "battery_charging_voltage_c": SensorEntityDescription( + key="battery_charging_voltage_c", + name="Battery Charging Voltage C", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLTAGE, + entity_registry_enabled_default=False, + ), + "battery_power_charge_total": SensorEntityDescription( + key="battery_power_charge_total", + name="Battery Power Charge Total", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + ), + "battery_power_charge_a": SensorEntityDescription( + key="battery_power_charge_a", + name="Battery Power Charge A", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + entity_registry_enabled_default=False, + ), + "battery_power_charge_b": SensorEntityDescription( + key="battery_power_charge_b", + name="Battery Power Charge B", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + entity_registry_enabled_default=False, + ), + "battery_power_charge_c": SensorEntityDescription( + key="battery_power_charge_c", + name="Battery Power Charge C", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + entity_registry_enabled_default=False, + ), + "battery_charge_total": SensorEntityDescription( + key="battery_charge_total", + name="Battery Charge Total", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + ), + "battery_charge_a": SensorEntityDescription( + key="battery_charge_a", + name="Battery Charge A", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + entity_registry_enabled_default=False, + ), + "battery_charge_b": SensorEntityDescription( + key="battery_charge_b", + name="Battery Charge B", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + entity_registry_enabled_default=False, + ), + "battery_charge_c": SensorEntityDescription( + key="battery_charge_c", + name="Battery Charge C", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + entity_registry_enabled_default=False, + ), + "battery_power_discharge_total": SensorEntityDescription( + key="battery_power_discharge_total", + name="Battery Power Discharge Total", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + ), + "battery_power_discharge_a": SensorEntityDescription( + key="battery_power_discharge_a", + name="Battery Power Discharge A", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + entity_registry_enabled_default=False, + ), + "battery_power_discharge_b": SensorEntityDescription( + key="battery_power_discharge_b", + name="Battery Power Discharge B", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + entity_registry_enabled_default=False, + ), + "battery_power_discharge_c": SensorEntityDescription( + key="battery_power_discharge_c", + name="Battery Power Discharge C", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + entity_registry_enabled_default=False, + ), + "battery_discharge_total": SensorEntityDescription( + key="battery_discharge_total", + name="Battery Discharge Total", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + ), + "battery_discharge_a": SensorEntityDescription( + key="battery_discharge_a", + name="Battery Discharge A", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + entity_registry_enabled_default=False, + ), + "battery_discharge_b": SensorEntityDescription( + key="battery_discharge_b", + name="Battery Discharge B", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + entity_registry_enabled_default=False, + ), + "battery_discharge_c": SensorEntityDescription( + key="battery_discharge_c", + name="Battery Discharge C", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + entity_registry_enabled_default=False, + ), + "inverter_power_limit": SensorEntityDescription( + key="inverter_power_limit", + name="Inverter Power Limit", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + ), +} + async def async_setup_entry( hass: HomeAssistant, @@ -45,6 +813,7 @@ async def async_setup_entry( SMAsensor( coordinator, config_entry.unique_id, + SENSOR_ENTITIES.get(sensor.name), device_info, sensor, ) @@ -60,22 +829,23 @@ def __init__( self, coordinator: DataUpdateCoordinator, config_entry_unique_id: str, + description: SensorEntityDescription | None, device_info: DeviceInfo, pysma_sensor: pysma.sensor.Sensor, ) -> None: """Initialize the sensor.""" super().__init__(coordinator) + if description is not None: + self.entity_description = description + else: + self._attr_name = pysma_sensor.name + self._sensor = pysma_sensor - self._enabled_default = self._sensor.enabled - self._config_entry_unique_id = config_entry_unique_id - self._attr_device_info = device_info - if self.native_unit_of_measurement == UnitOfEnergy.KILO_WATT_HOUR: - self._attr_state_class = SensorStateClass.TOTAL_INCREASING - self._attr_device_class = SensorDeviceClass.ENERGY - if self.native_unit_of_measurement == UnitOfPower.WATT: - self._attr_state_class = SensorStateClass.MEASUREMENT - self._attr_device_class = SensorDeviceClass.POWER + self._attr_device_info = device_info + self._attr_unique_id = ( + f"{config_entry_unique_id}-{pysma_sensor.key}_{pysma_sensor.key_idx}" + ) # Set sensor enabled to False. # Will be enabled by async_added_to_hass if actually used. @@ -83,36 +853,19 @@ def __init__( @property def name(self) -> str: - """Return the name of the sensor.""" + """Return the name of the sensor prefixed with the device name.""" if self._attr_device_info is None or not ( name_prefix := self._attr_device_info.get("name") ): name_prefix = "SMA" - return f"{name_prefix} {self._sensor.name}" + return f"{name_prefix} {super().name}" @property def native_value(self) -> StateType: """Return the state of the sensor.""" return self._sensor.value - @property - def native_unit_of_measurement(self) -> str | None: - """Return the unit the value is expressed in.""" - return self._sensor.unit - - @property - def unique_id(self) -> str: - """Return a unique identifier for this sensor.""" - return ( - f"{self._config_entry_unique_id}-{self._sensor.key}_{self._sensor.key_idx}" - ) - - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - return self._enabled_default - async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" await super().async_added_to_hass() diff --git a/homeassistant/components/smappee/binary_sensor.py b/homeassistant/components/smappee/binary_sensor.py index 71bbaa472aef5b..ed09b51ff25376 100644 --- a/homeassistant/components/smappee/binary_sensor.py +++ b/homeassistant/components/smappee/binary_sensor.py @@ -15,6 +15,23 @@ BINARY_SENSOR_PREFIX = "Appliance" PRESENCE_PREFIX = "Presence" +ICON_MAPPING = { + "Car Charger": "mdi:car", + "Coffeemaker": "mdi:coffee", + "Clothes Dryer": "mdi:tumble-dryer", + "Clothes Iron": "mdi:hanger", + "Dishwasher": "mdi:dishwasher", + "Lights": "mdi:lightbulb", + "Fan": "mdi:fan", + "Freezer": "mdi:fridge", + "Microwave": "mdi:microwave", + "Oven": "mdi:stove", + "Refrigerator": "mdi:fridge", + "Stove": "mdi:stove", + "Washing Machine": "mdi:washing-machine", + "Water Pump": "mdi:water-pump", +} + async def async_setup_entry( hass: HomeAssistant, @@ -48,54 +65,33 @@ async def async_setup_entry( class SmappeePresence(BinarySensorEntity): """Implementation of a Smappee presence binary sensor.""" + _attr_device_class = BinarySensorDeviceClass.PRESENCE + def __init__(self, smappee_base, service_location): """Initialize the Smappee sensor.""" self._smappee_base = smappee_base self._service_location = service_location - self._state = self._service_location.is_present - - @property - def name(self): - """Return the name of the binary sensor.""" - return f"{self._service_location.service_location_name} - {PRESENCE_PREFIX}" - - @property - def is_on(self): - """Return if the binary sensor is turned on.""" - return self._state - - @property - def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - return BinarySensorDeviceClass.PRESENCE - - @property - def unique_id( - self, - ): - """Return the unique ID for this binary sensor.""" - return ( - f"{self._service_location.device_serial_number}-" - f"{self._service_location.service_location_id}-" + self._attr_name = ( + f"{service_location.service_location_name} - {PRESENCE_PREFIX}" + ) + self._attr_unique_id = ( + f"{service_location.device_serial_number}-" + f"{service_location.service_location_id}-" f"{BinarySensorDeviceClass.PRESENCE}" ) - - @property - def device_info(self) -> DeviceInfo: - """Return the device info for this binary sensor.""" - return DeviceInfo( - identifiers={(DOMAIN, self._service_location.device_serial_number)}, + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, service_location.device_serial_number)}, manufacturer="Smappee", - model=self._service_location.device_model, - name=self._service_location.service_location_name, - sw_version=self._service_location.firmware_version, + model=service_location.device_model, + name=service_location.service_location_name, + sw_version=service_location.firmware_version, ) async def async_update(self) -> None: """Get the latest data from Smappee and update the state.""" await self._smappee_base.async_update() - self._state = self._service_location.is_present + self._attr_is_on = self._service_location.is_present class SmappeeAppliance(BinarySensorEntity): @@ -113,70 +109,28 @@ def __init__( self._smappee_base = smappee_base self._service_location = service_location self._appliance_id = appliance_id - self._appliance_name = appliance_name - self._appliance_type = appliance_type - self._state = False - - @property - def name(self): - """Return the name of the sensor.""" - return ( - f"{self._service_location.service_location_name} - " + self._attr_name = ( + f"{service_location.service_location_name} - " f"{BINARY_SENSOR_PREFIX} - " - f"{self._appliance_name if self._appliance_name != '' else self._appliance_type}" + f"{appliance_name if appliance_name != '' else appliance_type}" ) - - @property - def is_on(self): - """Return if the binary sensor is turned on.""" - return self._state - - @property - def icon(self): - """Icon to use in the frontend.""" - icon_mapping = { - "Car Charger": "mdi:car", - "Coffeemaker": "mdi:coffee", - "Clothes Dryer": "mdi:tumble-dryer", - "Clothes Iron": "mdi:hanger", - "Dishwasher": "mdi:dishwasher", - "Lights": "mdi:lightbulb", - "Fan": "mdi:fan", - "Freezer": "mdi:fridge", - "Microwave": "mdi:microwave", - "Oven": "mdi:stove", - "Refrigerator": "mdi:fridge", - "Stove": "mdi:stove", - "Washing Machine": "mdi:washing-machine", - "Water Pump": "mdi:water-pump", - } - return icon_mapping.get(self._appliance_type) - - @property - def unique_id( - self, - ): - """Return the unique ID for this binary sensor.""" - return ( - f"{self._service_location.device_serial_number}-" - f"{self._service_location.service_location_id}-" - f"appliance-{self._appliance_id}" + self._attr_unique_id = ( + f"{service_location.device_serial_number}-" + f"{service_location.service_location_id}-" + f"appliance-{appliance_id}" ) - - @property - def device_info(self) -> DeviceInfo: - """Return the device info for this binary sensor.""" - return DeviceInfo( - identifiers={(DOMAIN, self._service_location.device_serial_number)}, + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, service_location.device_serial_number)}, manufacturer="Smappee", - model=self._service_location.device_model, - name=self._service_location.service_location_name, - sw_version=self._service_location.firmware_version, + model=service_location.device_model, + name=service_location.service_location_name, + sw_version=service_location.firmware_version, ) + self._attr_icon = ICON_MAPPING.get(appliance_type) async def async_update(self) -> None: """Get the latest data from Smappee and update the state.""" await self._smappee_base.async_update() appliance = self._service_location.appliances.get(self._appliance_id) - self._state = bool(appliance.state) + self._attr_is_on = bool(appliance.state) diff --git a/homeassistant/components/smappee/sensor.py b/homeassistant/components/smappee/sensor.py index 4228f57ea4684b..82bc60936b3394 100644 --- a/homeassistant/components/smappee/sensor.py +++ b/homeassistant/components/smappee/sensor.py @@ -341,6 +341,13 @@ def __init__( self.entity_description = description self._smappee_base = smappee_base self._service_location = service_location + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, service_location.device_serial_number)}, + manufacturer="Smappee", + model=service_location.device_model, + name=service_location.service_location_name, + sw_version=service_location.firmware_version, + ) @property def name(self): @@ -372,17 +379,6 @@ def unique_id(self): f"{sensor_key}" ) - @property - def device_info(self) -> DeviceInfo: - """Return the device info for this sensor.""" - return DeviceInfo( - identifiers={(DOMAIN, self._service_location.device_serial_number)}, - manufacturer="Smappee", - model=self._service_location.device_model, - name=self._service_location.service_location_name, - sw_version=self._service_location.firmware_version, - ) - async def async_update(self) -> None: """Get the latest data from Smappee and update the state.""" await self._smappee_base.async_update() diff --git a/homeassistant/components/smappee/switch.py b/homeassistant/components/smappee/switch.py index 1928e717f22a86..238e41af8ffd0a 100644 --- a/homeassistant/components/smappee/switch.py +++ b/homeassistant/components/smappee/switch.py @@ -74,10 +74,17 @@ def __init__( self._actuator_type = actuator_type self._actuator_serialnumber = actuator_serialnumber self._actuator_state_option = actuator_state_option - self._state = self._service_location.actuators.get(actuator_id).state - self._connection_state = self._service_location.actuators.get( + self._state = service_location.actuators.get(actuator_id).state + self._connection_state = service_location.actuators.get( actuator_id ).connection_state + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, service_location.device_serial_number)}, + manufacturer="Smappee", + model=service_location.device_model, + name=service_location.service_location_name, + sw_version=service_location.firmware_version, + ) @property def name(self): @@ -153,17 +160,6 @@ def unique_id( f"{self._actuator_id}" ) - @property - def device_info(self) -> DeviceInfo: - """Return the device info for this switch.""" - return DeviceInfo( - identifiers={(DOMAIN, self._service_location.device_serial_number)}, - manufacturer="Smappee", - model=self._service_location.device_model, - name=self._service_location.service_location_name, - sw_version=self._service_location.firmware_version, - ) - async def async_update(self) -> None: """Get the latest data from Smappee and update the state.""" await self._smappee_base.async_update() diff --git a/homeassistant/components/smart_meter_texas/sensor.py b/homeassistant/components/smart_meter_texas/sensor.py index 7552f2c0697d29..84ad68fabc3628 100644 --- a/homeassistant/components/smart_meter_texas/sensor.py +++ b/homeassistant/components/smart_meter_texas/sensor.py @@ -47,52 +47,33 @@ class SmartMeterTexasSensor(CoordinatorEntity, RestoreEntity, SensorEntity): _attr_device_class = SensorDeviceClass.ENERGY _attr_state_class = SensorStateClass.TOTAL_INCREASING _attr_native_unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR + _attr_available = False def __init__(self, meter: Meter, coordinator: DataUpdateCoordinator) -> None: """Initialize the sensor.""" super().__init__(coordinator) self.meter = meter - self._state = None - self._available = False - - @property - def name(self): - """Device Name.""" - return f"{ELECTRIC_METER} {self.meter.meter}" - - @property - def unique_id(self): - """Device Uniqueid.""" - return f"{self.meter.esiid}_{self.meter.meter}" - - @property - def available(self): - """Return True if entity is available.""" - return self._available - - @property - def native_value(self): - """Get the latest reading.""" - return self._state + self._attr_name = f"{ELECTRIC_METER} {meter.meter}" + self._attr_unique_id = f"{meter.esiid}_{meter.meter}" @property def extra_state_attributes(self): """Return the device specific state attributes.""" - attributes = { + return { METER_NUMBER: self.meter.meter, ESIID: self.meter.esiid, CONF_ADDRESS: self.meter.address, } - return attributes @callback def _state_update(self): """Call when the coordinator has an update.""" - self._available = self.coordinator.last_update_success - if self._available: - self._state = self.meter.reading + self._attr_available = self.coordinator.last_update_success + if self._attr_available: + self._attr_native_value = self.meter.reading self.async_write_ha_state() + # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self): """Subscribe to updates.""" self.async_on_remove(self.coordinator.async_add_listener(self._state_update)) @@ -104,5 +85,5 @@ async def async_added_to_hass(self): return if last_state := await self.async_get_last_state(): - self._state = last_state.state - self._available = True + self._attr_native_value = last_state.state + self._attr_available = True diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index 22856bdb05ba30..cdf04be29f303c 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -429,6 +429,17 @@ def __init__(self, device: DeviceEntity) -> None: """Initialize the instance.""" self._device = device self._dispatcher_remove = None + self._attr_name = device.label + self._attr_unique_id = device.device_id + self._attr_device_info = DeviceInfo( + configuration_url="https://account.smartthings.com", + identifiers={(DOMAIN, device.device_id)}, + manufacturer=device.status.ocf_manufacturer_name, + model=device.status.ocf_model_number, + name=device.label, + hw_version=device.status.ocf_hardware_version, + sw_version=device.status.ocf_firmware_version, + ) async def async_added_to_hass(self): """Device added to hass.""" @@ -446,26 +457,3 @@ async def async_will_remove_from_hass(self) -> None: """Disconnect the device when removed.""" if self._dispatcher_remove: self._dispatcher_remove() - - @property - def device_info(self) -> DeviceInfo: - """Get attributes about the device.""" - return DeviceInfo( - configuration_url="https://account.smartthings.com", - identifiers={(DOMAIN, self._device.device_id)}, - manufacturer=self._device.status.ocf_manufacturer_name, - model=self._device.status.ocf_model_number, - name=self._device.label, - hw_version=self._device.status.ocf_hardware_version, - sw_version=self._device.status.ocf_firmware_version, - ) - - @property - def name(self) -> str: - """Return the name of the device.""" - return self._device.label - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return self._device.device_id diff --git a/homeassistant/components/smartthings/binary_sensor.py b/homeassistant/components/smartthings/binary_sensor.py index d0ffd0ac29d5f6..25f9fa224ff6f3 100644 --- a/homeassistant/components/smartthings/binary_sensor.py +++ b/homeassistant/components/smartthings/binary_sensor.py @@ -73,28 +73,12 @@ def __init__(self, device, attribute): """Init the class.""" super().__init__(device) self._attribute = attribute - - @property - def name(self) -> str: - """Return the name of the binary sensor.""" - return f"{self._device.label} {self._attribute}" - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return f"{self._device.device_id}.{self._attribute}" + self._attr_name = f"{device.label} {attribute}" + self._attr_unique_id = f"{device.device_id}.{attribute}" + self._attr_device_class = ATTRIB_TO_CLASS[attribute] + self._attr_entity_category = ATTRIB_TO_ENTTIY_CATEGORY.get(attribute) @property def is_on(self): """Return true if the binary sensor is on.""" return self._device.status.is_on(self._attribute) - - @property - def device_class(self): - """Return the class of this device.""" - return ATTRIB_TO_CLASS[self._attribute] - - @property - def entity_category(self): - """Return the entity category of this device.""" - return ATTRIB_TO_ENTTIY_CATEGORY.get(self._attribute) diff --git a/homeassistant/components/smartthings/cover.py b/homeassistant/components/smartthings/cover.py index 5d7e29c131215c..83522c61794e98 100644 --- a/homeassistant/components/smartthings/cover.py +++ b/homeassistant/components/smartthings/cover.py @@ -77,10 +77,8 @@ class SmartThingsCover(SmartThingsEntity, CoverEntity): def __init__(self, device): """Initialize the cover class.""" super().__init__(device) - self._device_class = None self._current_cover_position = None self._state = None - self._state_attrs = None self._attr_supported_features = ( CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE ) @@ -90,6 +88,13 @@ def __init__(self, device): ): self._attr_supported_features |= CoverEntityFeature.SET_POSITION + if Capability.door_control in device.capabilities: + self._attr_device_class = CoverDeviceClass.DOOR + elif Capability.window_shade in device.capabilities: + self._attr_device_class = CoverDeviceClass.SHADE + elif Capability.garage_door_control in device.capabilities: + self._attr_device_class = CoverDeviceClass.GARAGE + async def async_close_cover(self, **kwargs: Any) -> None: """Close cover.""" # Same command for all 3 supported capabilities @@ -121,24 +126,21 @@ async def async_set_cover_position(self, **kwargs: Any) -> None: async def async_update(self) -> None: """Update the attrs of the cover.""" if Capability.door_control in self._device.capabilities: - self._device_class = CoverDeviceClass.DOOR self._state = VALUE_TO_STATE.get(self._device.status.door) elif Capability.window_shade in self._device.capabilities: - self._device_class = CoverDeviceClass.SHADE self._state = VALUE_TO_STATE.get(self._device.status.window_shade) elif Capability.garage_door_control in self._device.capabilities: - self._device_class = CoverDeviceClass.GARAGE self._state = VALUE_TO_STATE.get(self._device.status.door) if Capability.window_shade_level in self._device.capabilities: - self._current_cover_position = self._device.status.shade_level + self._attr_current_cover_position = self._device.status.shade_level elif Capability.switch_level in self._device.capabilities: - self._current_cover_position = self._device.status.level + self._attr_current_cover_position = self._device.status.level - self._state_attrs = {} + self._attr_extra_state_attributes = {} battery = self._device.status.attributes[Attribute.battery].value if battery is not None: - self._state_attrs[ATTR_BATTERY_LEVEL] = battery + self._attr_extra_state_attributes[ATTR_BATTERY_LEVEL] = battery @property def is_opening(self) -> bool: @@ -156,18 +158,3 @@ def is_closed(self) -> bool | None: if self._state == STATE_CLOSED: return True return None if self._state is None else False - - @property - def current_cover_position(self) -> int | None: - """Return current position of cover.""" - return self._current_cover_position - - @property - def device_class(self) -> CoverDeviceClass | None: - """Define this cover as a garage door.""" - return self._device_class - - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Get additional state attributes.""" - return self._state_attrs diff --git a/homeassistant/components/smartthings/fan.py b/homeassistant/components/smartthings/fan.py index 7278f350dc1745..ebf80e22909c52 100644 --- a/homeassistant/components/smartthings/fan.py +++ b/homeassistant/components/smartthings/fan.py @@ -52,6 +52,7 @@ class SmartThingsFan(SmartThingsEntity, FanEntity): """Define a SmartThings Fan.""" _attr_supported_features = FanEntityFeature.SET_SPEED + _attr_speed_count = int_states_in_range(SPEED_RANGE) async def async_set_percentage(self, percentage: int) -> None: """Set the speed percentage of the fan.""" @@ -94,8 +95,3 @@ def is_on(self) -> bool: def percentage(self) -> int: """Return the current speed percentage.""" return ranged_value_to_percentage(SPEED_RANGE, self._device.status.fan_speed) - - @property - def speed_count(self) -> int: - """Return the number of speeds the fan supports.""" - return int_states_in_range(SPEED_RANGE) diff --git a/homeassistant/components/smartthings/light.py b/homeassistant/components/smartthings/light.py index 37237323d1ce20..58623e08394884 100644 --- a/homeassistant/components/smartthings/light.py +++ b/homeassistant/components/smartthings/light.py @@ -75,12 +75,19 @@ class SmartThingsLight(SmartThingsEntity, LightEntity): _attr_supported_color_modes: set[ColorMode] + # SmartThings does not expose this attribute, instead it's + # implemented within each device-type handler. This value is the + # lowest kelvin found supported across 20+ handlers. + _attr_max_mireds = 500 # 2000K + + # SmartThings does not expose this attribute, instead it's + # implemented within each device-type handler. This value is the + # highest kelvin found supported across 20+ handlers. + _attr_min_mireds = 111 # 9000K + def __init__(self, device): """Initialize a SmartThingsLight.""" super().__init__(device) - self._brightness = None - self._color_temp = None - self._hs_color = None self._attr_supported_color_modes = self._determine_color_modes() self._attr_supported_features = self._determine_features() @@ -151,17 +158,17 @@ async def async_update(self) -> None: """Update entity attributes when the device status has changed.""" # Brightness and transition if brightness_supported(self._attr_supported_color_modes): - self._brightness = int( + self._attr_brightness = int( convert_scale(self._device.status.level, 100, 255, 0) ) # Color Temperature if ColorMode.COLOR_TEMP in self._attr_supported_color_modes: - self._color_temp = color_util.color_temperature_kelvin_to_mired( + self._attr_color_temp = color_util.color_temperature_kelvin_to_mired( self._device.status.color_temperature ) # Color if ColorMode.HS in self._attr_supported_color_modes: - self._hs_color = ( + self._attr_hs_color = ( convert_scale(self._device.status.hue, 100, 360), self._device.status.saturation, ) @@ -197,42 +204,11 @@ def color_mode(self) -> ColorMode: return list(self._attr_supported_color_modes)[0] # The light supports hs + color temp, determine which one it is - if self._hs_color and self._hs_color[1]: + if self._attr_hs_color and self._attr_hs_color[1]: return ColorMode.HS return ColorMode.COLOR_TEMP - @property - def brightness(self): - """Return the brightness of this light between 0..255.""" - return self._brightness - - @property - def color_temp(self): - """Return the CT color value in mireds.""" - return self._color_temp - - @property - def hs_color(self): - """Return the hue and saturation color value [float, float].""" - return self._hs_color - @property def is_on(self) -> bool: """Return true if light is on.""" return self._device.status.switch - - @property - def max_mireds(self): - """Return the warmest color_temp that this light supports.""" - # SmartThings does not expose this attribute, instead it's - # implemented within each device-type handler. This value is the - # lowest kelvin found supported across 20+ handlers. - return 500 # 2000K - - @property - def min_mireds(self): - """Return the coldest color_temp that this light supports.""" - # SmartThings does not expose this attribute, instead it's - # implemented within each device-type handler. This value is the - # highest kelvin found supported across 20+ handlers. - return 111 # 9000K diff --git a/homeassistant/components/smartthings/scene.py b/homeassistant/components/smartthings/scene.py index 9ccda5fd5e6782..ffdb900237e744 100644 --- a/homeassistant/components/smartthings/scene.py +++ b/homeassistant/components/smartthings/scene.py @@ -25,6 +25,8 @@ class SmartThingsScene(Scene): def __init__(self, scene): """Init the scene class.""" self._scene = scene + self._attr_name = scene.name + self._attr_unique_id = scene.scene_id async def async_activate(self, **kwargs: Any) -> None: """Activate scene.""" @@ -38,13 +40,3 @@ def extra_state_attributes(self): "color": self._scene.color, "location_id": self._scene.location_id, } - - @property - def name(self) -> str: - """Return the name of the device.""" - return self._scene.name - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return self._scene.scene_id diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 823ca793972c31..18016a88d29405 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -629,44 +629,30 @@ def __init__( attribute: str, name: str, default_unit: str, - device_class: str, + device_class: SensorDeviceClass, state_class: str | None, entity_category: EntityCategory | None, ) -> None: """Init the class.""" super().__init__(device) self._attribute = attribute - self._name = name - self._device_class = device_class + self._attr_name = f"{device.label} {name}" + self._attr_unique_id = f"{device.device_id}.{attribute}" + self._attr_device_class = device_class self._default_unit = default_unit self._attr_state_class = state_class self._attr_entity_category = entity_category - @property - def name(self) -> str: - """Return the name of the sensor.""" - return f"{self._device.label} {self._name}" - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return f"{self._device.device_id}.{self._attribute}" - @property def native_value(self): """Return the state of the sensor.""" value = self._device.status.attributes[self._attribute].value - if self._device_class != SensorDeviceClass.TIMESTAMP: + if self.device_class != SensorDeviceClass.TIMESTAMP: return value return dt_util.parse_datetime(value) - @property - def device_class(self): - """Return the device class of the sensor.""" - return self._device_class - @property def native_unit_of_measurement(self): """Return the unit this state is expressed in.""" @@ -681,16 +667,8 @@ def __init__(self, device, index): """Init the class.""" super().__init__(device) self._index = index - - @property - def name(self) -> str: - """Return the name of the sensor.""" - return f"{self._device.label} {THREE_AXIS_NAMES[self._index]}" - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return f"{self._device.device_id}.{THREE_AXIS_NAMES[self._index]}" + self._attr_name = f"{device.label} {THREE_AXIS_NAMES[index]}" + self._attr_unique_id = f"{device.device_id} {THREE_AXIS_NAMES[index]}" @property def native_value(self): @@ -713,19 +691,16 @@ def __init__( """Init the class.""" super().__init__(device) self.report_name = report_name - self._attr_state_class = SensorStateClass.MEASUREMENT - if self.report_name != "power": + self._attr_name = f"{device.label} {report_name}" + self._attr_unique_id = f"{device.device_id}.{report_name}_meter" + if self.report_name == "power": + self._attr_state_class = SensorStateClass.MEASUREMENT + self._attr_device_class = SensorDeviceClass.POWER + self._attr_native_unit_of_measurement = UnitOfPower.WATT + else: self._attr_state_class = SensorStateClass.TOTAL_INCREASING - - @property - def name(self) -> str: - """Return the name of the sensor.""" - return f"{self._device.label} {self.report_name}" - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return f"{self._device.device_id}.{self.report_name}_meter" + self._attr_device_class = SensorDeviceClass.ENERGY + self._attr_native_unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR @property def native_value(self): @@ -737,20 +712,6 @@ def native_value(self): return value[self.report_name] return value[self.report_name] / 1000 - @property - def device_class(self): - """Return the device class of the sensor.""" - if self.report_name == "power": - return SensorDeviceClass.POWER - return SensorDeviceClass.ENERGY - - @property - def native_unit_of_measurement(self): - """Return the unit this state is expressed in.""" - if self.report_name == "power": - return UnitOfPower.WATT - return UnitOfEnergy.KILO_WATT_HOUR - @property def extra_state_attributes(self): """Return specific state attributes.""" diff --git a/homeassistant/components/smarttub/binary_sensor.py b/homeassistant/components/smarttub/binary_sensor.py index a1159bcc0efd36..99037cd623c858 100644 --- a/homeassistant/components/smarttub/binary_sensor.py +++ b/homeassistant/components/smarttub/binary_sensor.py @@ -76,19 +76,13 @@ class SmartTubOnline(SmartTubSensorBase, BinarySensorEntity): """A binary sensor indicating whether the spa is currently online (connected to the cloud).""" _attr_device_class = BinarySensorDeviceClass.CONNECTIVITY + # 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): """Initialize the entity.""" super().__init__(coordinator, spa, "Online", "online") - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry. - - This seems to be very noisy and not generally useful, so disable by default. - """ - return False - @property def is_on(self) -> bool: """Return true if the binary sensor is on.""" @@ -108,11 +102,7 @@ def __init__(self, coordinator, spa, reminder): f"{reminder.name.title()} Reminder", ) self.reminder_id = reminder.id - - @property - def unique_id(self): - """Return a unique id for this sensor.""" - return f"{self.spa.id}-reminder-{self.reminder_id}" + self._attr_unique_id = f"{spa.id}-reminder-{reminder.id}" @property def reminder(self) -> SpaReminder: diff --git a/homeassistant/components/smarttub/climate.py b/homeassistant/components/smarttub/climate.py index a938bde6fd15f7..b2d4fbf17c4641 100644 --- a/homeassistant/components/smarttub/climate.py +++ b/homeassistant/components/smarttub/climate.py @@ -64,6 +64,7 @@ class SmartTubThermostat(SmartTubEntity, ClimateEntity): ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TARGET_TEMPERATURE ) _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_preset_modes = list(PRESET_MODES.values()) def __init__(self, coordinator, spa): """Initialize the entity.""" @@ -104,11 +105,6 @@ def preset_mode(self): """Return the current preset mode.""" return PRESET_MODES[self.spa_status.heat_mode] - @property - def preset_modes(self): - """Return the available preset modes.""" - return list(PRESET_MODES.values()) - @property def current_temperature(self): """Return the current water temperature.""" diff --git a/homeassistant/components/smarttub/entity.py b/homeassistant/components/smarttub/entity.py index 7f2a739c26e0b8..6e6cb00a7d3b7a 100644 --- a/homeassistant/components/smarttub/entity.py +++ b/homeassistant/components/smarttub/entity.py @@ -25,27 +25,14 @@ def __init__( super().__init__(coordinator) self.spa = spa - self._entity_name = entity_name - - @property - def unique_id(self) -> str: - """Return a unique id for the entity.""" - return f"{self.spa.id}-{self._entity_name}" - - @property - def device_info(self) -> DeviceInfo: - """Return device info.""" - return DeviceInfo( - identifiers={(DOMAIN, self.spa.id)}, - manufacturer=self.spa.brand, - model=self.spa.model, + self._attr_unique_id = f"{spa.id}-{entity_name}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, spa.id)}, + manufacturer=spa.brand, + model=spa.model, ) - - @property - def name(self) -> str: - """Return the name of the entity.""" spa_name = get_spa_name(self.spa) - return f"{spa_name} {self._entity_name}" + self._attr_name = f"{spa_name} {entity_name}" @property def spa_status(self) -> smarttub.SpaState: @@ -57,12 +44,12 @@ def spa_status(self) -> smarttub.SpaState: class SmartTubSensorBase(SmartTubEntity): """Base class for SmartTub sensors.""" - def __init__(self, coordinator, spa, sensor_name, attr_name): + def __init__(self, coordinator, spa, sensor_name, state_key): """Initialize the entity.""" super().__init__(coordinator, spa, sensor_name) - self._attr_name = attr_name + self._state_key = state_key @property def _state(self): """Retrieve the underlying state from the spa.""" - return getattr(self.spa_status, self._attr_name) + return getattr(self.spa_status, self._state_key) diff --git a/homeassistant/components/smarttub/light.py b/homeassistant/components/smarttub/light.py index f7e229449e04cd..d89cdba336758d 100644 --- a/homeassistant/components/smarttub/light.py +++ b/homeassistant/components/smarttub/light.py @@ -53,23 +53,15 @@ def __init__(self, coordinator, light): """Initialize the entity.""" super().__init__(coordinator, light.spa, "light") self.light_zone = light.zone + self._attr_unique_id = f"{super().unique_id}-{light.zone}" + spa_name = get_spa_name(self.spa) + self._attr_name = f"{spa_name} Light {light.zone}" @property def light(self) -> SpaLight: """Return the underlying SpaLight object for this entity.""" return self.coordinator.data[self.spa.id][ATTR_LIGHTS][self.light_zone] - @property - def unique_id(self) -> str: - """Return a unique ID for this light entity.""" - return f"{super().unique_id}-{self.light_zone}" - - @property - def name(self) -> str: - """Return a name for this light entity.""" - spa_name = get_spa_name(self.spa) - return f"{spa_name} Light {self.light_zone}" - @property def brightness(self): """Return the brightness of this light between 0..255.""" diff --git a/homeassistant/components/smarttub/switch.py b/homeassistant/components/smarttub/switch.py index e105963bc01b22..aeeca46aaa9135 100644 --- a/homeassistant/components/smarttub/switch.py +++ b/homeassistant/components/smarttub/switch.py @@ -38,17 +38,13 @@ def __init__(self, coordinator, pump: SpaPump) -> None: super().__init__(coordinator, pump.spa, "pump") self.pump_id = pump.id self.pump_type = pump.type + self._attr_unique_id = f"{super().unique_id}-{pump.id}" @property def pump(self) -> SpaPump: """Return the underlying SpaPump object for this entity.""" return self.coordinator.data[self.spa.id][ATTR_PUMPS][self.pump_id] - @property - def unique_id(self) -> str: - """Return a unique ID for this pump entity.""" - return f"{super().unique_id}-{self.pump_id}" - @property def name(self) -> str: """Return a name for this pump entity.""" diff --git a/homeassistant/components/sms/__init__.py b/homeassistant/components/sms/__init__.py index 824a95e36b12c5..a606b83896f1f9 100644 --- a/homeassistant/components/sms/__init__.py +++ b/homeassistant/components/sms/__init__.py @@ -1,9 +1,6 @@ """The sms component.""" -import asyncio -from datetime import timedelta import logging -import gammu import voluptuous as vol from homeassistant.config_entries import ConfigEntry @@ -12,12 +9,10 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.typing import ConfigType -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( CONF_BAUD_SPEED, DEFAULT_BAUD_SPEED, - DEFAULT_SCAN_INTERVAL, DOMAIN, GATEWAY, HASS_CONFIG, @@ -25,6 +20,7 @@ SIGNAL_COORDINATOR, SMS_GATEWAY, ) +from .coordinator import NetworkCoordinator, SignalCoordinator from .gateway import create_sms_gateway _LOGGER = logging.getLogger(__name__) @@ -45,8 +41,6 @@ extra=vol.ALLOW_EXTRA, ) -_LOGGER = logging.getLogger(__name__) - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Configure Gammu state machine.""" @@ -107,47 +101,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await gateway.terminate_async() return unload_ok - - -class SignalCoordinator(DataUpdateCoordinator): - """Signal strength coordinator.""" - - def __init__(self, hass, gateway): - """Initialize signal strength coordinator.""" - super().__init__( - hass, - _LOGGER, - name="Device signal state", - update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL), - ) - self._gateway = gateway - - async def _async_update_data(self): - """Fetch device signal quality.""" - try: - async with asyncio.timeout(10): - return await self._gateway.get_signal_quality_async() - except gammu.GSMError as exc: - raise UpdateFailed(f"Error communicating with device: {exc}") from exc - - -class NetworkCoordinator(DataUpdateCoordinator): - """Network info coordinator.""" - - def __init__(self, hass, gateway): - """Initialize network info coordinator.""" - super().__init__( - hass, - _LOGGER, - name="Device network state", - update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL), - ) - self._gateway = gateway - - async def _async_update_data(self): - """Fetch device network info.""" - try: - async with asyncio.timeout(10): - return await self._gateway.get_network_info_async() - except gammu.GSMError as exc: - raise UpdateFailed(f"Error communicating with device: {exc}") from exc diff --git a/homeassistant/components/sms/coordinator.py b/homeassistant/components/sms/coordinator.py new file mode 100644 index 00000000000000..fd212fce4f2f11 --- /dev/null +++ b/homeassistant/components/sms/coordinator.py @@ -0,0 +1,56 @@ +"""DataUpdateCoordinators for the sms integration.""" +import asyncio +from datetime import timedelta +import logging + +import gammu + +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DEFAULT_SCAN_INTERVAL + +_LOGGER = logging.getLogger(__name__) + + +class SignalCoordinator(DataUpdateCoordinator): + """Signal strength coordinator.""" + + def __init__(self, hass, gateway): + """Initialize signal strength coordinator.""" + super().__init__( + hass, + _LOGGER, + name="Device signal state", + update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL), + ) + self._gateway = gateway + + async def _async_update_data(self): + """Fetch device signal quality.""" + try: + async with asyncio.timeout(10): + return await self._gateway.get_signal_quality_async() + except gammu.GSMError as exc: + raise UpdateFailed(f"Error communicating with device: {exc}") from exc + + +class NetworkCoordinator(DataUpdateCoordinator): + """Network info coordinator.""" + + def __init__(self, hass, gateway): + """Initialize network info coordinator.""" + super().__init__( + hass, + _LOGGER, + name="Device network state", + update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL), + ) + self._gateway = gateway + + async def _async_update_data(self): + """Fetch device network info.""" + try: + async with asyncio.timeout(10): + return await self._gateway.get_network_info_async() + except gammu.GSMError as exc: + raise UpdateFailed(f"Error communicating with device: {exc}") from exc diff --git a/homeassistant/components/smtp/__init__.py b/homeassistant/components/smtp/__init__.py index abf54efdd9dcaf..5e7fb41c2127a1 100644 --- a/homeassistant/components/smtp/__init__.py +++ b/homeassistant/components/smtp/__init__.py @@ -1,6 +1 @@ """The smtp component.""" - -from homeassistant.const import Platform - -DOMAIN = "smtp" -PLATFORMS = [Platform.NOTIFY] diff --git a/homeassistant/components/smtp/const.py b/homeassistant/components/smtp/const.py new file mode 100644 index 00000000000000..1fa077a24fb8a6 --- /dev/null +++ b/homeassistant/components/smtp/const.py @@ -0,0 +1,22 @@ +"""Constants for the smtp integration.""" + +from typing import Final + +DOMAIN: Final = "smtp" + +ATTR_IMAGES: Final = "images" # optional embedded image file attachments +ATTR_HTML: Final = "html" +ATTR_SENDER_NAME: Final = "sender_name" + +CONF_ENCRYPTION: Final = "encryption" +CONF_DEBUG: Final = "debug" +CONF_SERVER: Final = "server" +CONF_SENDER_NAME: Final = "sender_name" + +DEFAULT_HOST: Final = "localhost" +DEFAULT_PORT: Final = 587 +DEFAULT_TIMEOUT: Final = 5 +DEFAULT_DEBUG: Final = False +DEFAULT_ENCRYPTION: Final = "starttls" + +ENCRYPTION_OPTIONS: Final = ["tls", "starttls", "none"] diff --git a/homeassistant/components/smtp/notify.py b/homeassistant/components/smtp/notify.py index 7037c239db3945..6836a0b9f6b670 100644 --- a/homeassistant/components/smtp/notify.py +++ b/homeassistant/components/smtp/notify.py @@ -28,6 +28,7 @@ CONF_TIMEOUT, CONF_USERNAME, CONF_VERIFY_SSL, + Platform, ) from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv @@ -36,25 +37,25 @@ import homeassistant.util.dt as dt_util from homeassistant.util.ssl import client_context -from . import DOMAIN, PLATFORMS - -_LOGGER = logging.getLogger(__name__) - -ATTR_IMAGES = "images" # optional embedded image file attachments -ATTR_HTML = "html" - -CONF_ENCRYPTION = "encryption" -CONF_DEBUG = "debug" -CONF_SERVER = "server" -CONF_SENDER_NAME = "sender_name" +from .const import ( + ATTR_HTML, + ATTR_IMAGES, + CONF_DEBUG, + CONF_ENCRYPTION, + CONF_SENDER_NAME, + CONF_SERVER, + DEFAULT_DEBUG, + DEFAULT_ENCRYPTION, + DEFAULT_HOST, + DEFAULT_PORT, + DEFAULT_TIMEOUT, + DOMAIN, + ENCRYPTION_OPTIONS, +) -DEFAULT_HOST = "localhost" -DEFAULT_PORT = 587 -DEFAULT_TIMEOUT = 5 -DEFAULT_DEBUG = False -DEFAULT_ENCRYPTION = "starttls" +PLATFORMS = [Platform.NOTIFY] -ENCRYPTION_OPTIONS = ["tls", "starttls", "none"] +_LOGGER = logging.getLogger(__name__) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { diff --git a/homeassistant/components/snapcast/media_player.py b/homeassistant/components/snapcast/media_player.py index 9dadae2e3e2a55..f0b6eccf8b40da 100644 --- a/homeassistant/components/snapcast/media_player.py +++ b/homeassistant/components/snapcast/media_player.py @@ -160,7 +160,7 @@ def __init__(self, group, uid_part, entry_id): self._attr_available = True self._group = group self._entry_id = entry_id - self._uid = f"{GROUP_PREFIX}{uid_part}_{self._group.identifier}" + self._attr_unique_id = f"{GROUP_PREFIX}{uid_part}_{self._group.identifier}" async def async_added_to_hass(self) -> None: """Subscribe to group events.""" @@ -184,11 +184,6 @@ def state(self) -> MediaPlayerState | None: return MediaPlayerState.IDLE return STREAM_STATUS.get(self._group.stream_status) - @property - def unique_id(self): - """Return the ID of snapcast group.""" - return self._uid - @property def identifier(self): """Return the snapcast identifier.""" @@ -260,7 +255,8 @@ def __init__(self, client, uid_part, entry_id): """Initialize the Snapcast client device.""" self._attr_available = True self._client = client - self._uid = f"{CLIENT_PREFIX}{uid_part}_{self._client.identifier}" + # Note: Host part is needed, when using multiple snapservers + self._attr_unique_id = f"{CLIENT_PREFIX}{uid_part}_{self._client.identifier}" self._entry_id = entry_id async def async_added_to_hass(self) -> None: @@ -278,14 +274,6 @@ def set_availability(self, available: bool) -> None: self._attr_available = available self.schedule_update_ha_state() - @property - def unique_id(self): - """Return the ID of this snapcast client. - - Note: Host part is needed, when using multiple snapservers - """ - return self._uid - @property def identifier(self): """Return the snapcast identifier.""" diff --git a/homeassistant/components/snooz/fan.py b/homeassistant/components/snooz/fan.py index c5b3e5b5b696cd..5cb80cb4189409 100644 --- a/homeassistant/components/snooz/fan.py +++ b/homeassistant/components/snooz/fan.py @@ -74,15 +74,15 @@ class SnoozFan(FanEntity, RestoreEntity): _attr_has_entity_name = True _attr_name = None + _attr_supported_features = FanEntityFeature.SET_SPEED + _attr_should_poll = False + _is_on: bool | None = None + _percentage: int | None = None def __init__(self, data: SnoozConfigurationData) -> None: """Initialize a Snooz fan entity.""" self._device = data.device self._attr_unique_id = data.device.address - self._attr_supported_features = FanEntityFeature.SET_SPEED - self._attr_should_poll = False - self._is_on: bool | None = None - self._percentage: int | None = None self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, data.device.address)}) @callback diff --git a/homeassistant/components/solaredge/sensor.py b/homeassistant/components/solaredge/sensor.py index e1ea7960086df5..f2c073c691865c 100644 --- a/homeassistant/components/solaredge/sensor.py +++ b/homeassistant/components/solaredge/sensor.py @@ -353,7 +353,7 @@ def unique_id(self) -> str | None: """Return a unique ID.""" if not self.data_service.site_id: return None - return f"{self.data_service.site_id}" + return str(self.data_service.site_id) class SolarEdgeInventorySensor(SolarEdgeSensorEntity): diff --git a/homeassistant/components/solarlog/__init__.py b/homeassistant/components/solarlog/__init__.py index e0ab838922b66c..95cf5cc45675b3 100644 --- a/homeassistant/components/solarlog/__init__.py +++ b/homeassistant/components/solarlog/__init__.py @@ -1,19 +1,10 @@ """Solar-Log integration.""" -from datetime import timedelta -import logging -from urllib.parse import ParseResult, urlparse - -from requests.exceptions import HTTPError, Timeout -from sunwatcher.solarlog.solarlog import SolarLog - from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import update_coordinator from .const import DOMAIN - -_LOGGER = logging.getLogger(__name__) +from .coordinator import SolarlogData PLATFORMS = [Platform.SENSOR] @@ -30,45 +21,3 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -class SolarlogData(update_coordinator.DataUpdateCoordinator): - """Get and update the latest data.""" - - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: - """Initialize the data object.""" - super().__init__( - hass, _LOGGER, name="SolarLog", update_interval=timedelta(seconds=60) - ) - - host_entry = entry.data[CONF_HOST] - - url = urlparse(host_entry, "http") - netloc = url.netloc or url.path - path = url.path if url.netloc else "" - url = ParseResult("http", netloc, path, *url[3:]) - self.unique_id = entry.entry_id - self.name = entry.title - self.host = url.geturl() - - async def _async_update_data(self): - """Update the data from the SolarLog device.""" - try: - data = await self.hass.async_add_executor_job(SolarLog, self.host) - except (OSError, Timeout, HTTPError) as err: - raise update_coordinator.UpdateFailed(err) from err - - if data.time.year == 1999: - raise update_coordinator.UpdateFailed( - "Invalid data returned (can happen after Solarlog restart)." - ) - - self.logger.debug( - ( - "Connection to Solarlog successful. Retrieving latest Solarlog update" - " of %s" - ), - data.time, - ) - - return data diff --git a/homeassistant/components/solarlog/coordinator.py b/homeassistant/components/solarlog/coordinator.py new file mode 100644 index 00000000000000..d363256f355a8a --- /dev/null +++ b/homeassistant/components/solarlog/coordinator.py @@ -0,0 +1,56 @@ +"""DataUpdateCoordinator for solarlog integration.""" +from datetime import timedelta +import logging +from urllib.parse import ParseResult, urlparse + +from requests.exceptions import HTTPError, Timeout +from sunwatcher.solarlog.solarlog import SolarLog + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.helpers import update_coordinator + +_LOGGER = logging.getLogger(__name__) + + +class SolarlogData(update_coordinator.DataUpdateCoordinator): + """Get and update the latest data.""" + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + """Initialize the data object.""" + super().__init__( + hass, _LOGGER, name="SolarLog", update_interval=timedelta(seconds=60) + ) + + host_entry = entry.data[CONF_HOST] + + url = urlparse(host_entry, "http") + netloc = url.netloc or url.path + path = url.path if url.netloc else "" + url = ParseResult("http", netloc, path, *url[3:]) + self.unique_id = entry.entry_id + self.name = entry.title + self.host = url.geturl() + + async def _async_update_data(self): + """Update the data from the SolarLog device.""" + try: + data = await self.hass.async_add_executor_job(SolarLog, self.host) + except (OSError, Timeout, HTTPError) as err: + raise update_coordinator.UpdateFailed(err) from err + + if data.time.year == 1999: + raise update_coordinator.UpdateFailed( + "Invalid data returned (can happen after Solarlog restart)." + ) + + self.logger.debug( + ( + "Connection to Solarlog successful. Retrieving latest Solarlog update" + " of %s" + ), + data.time, + ) + + return data diff --git a/homeassistant/components/soma/cover.py b/homeassistant/components/soma/cover.py index 26487756a44a6b..4aa2559b14049f 100644 --- a/homeassistant/components/soma/cover.py +++ b/homeassistant/components/soma/cover.py @@ -51,6 +51,8 @@ class SomaTilt(SomaEntity, CoverEntity): | CoverEntityFeature.STOP_TILT | CoverEntityFeature.SET_TILT_POSITION ) + CLOSED_UP_THRESHOLD = 80 + CLOSED_DOWN_THRESHOLD = 20 @property def current_cover_tilt_position(self) -> int: @@ -60,7 +62,12 @@ def current_cover_tilt_position(self) -> int: @property def is_closed(self) -> bool: """Return if the cover tilt is closed.""" - return self.current_position == 0 + if ( + self.current_position < self.CLOSED_DOWN_THRESHOLD + or self.current_position > self.CLOSED_UP_THRESHOLD + ): + return True + return False def close_cover_tilt(self, **kwargs: Any) -> None: """Close the cover tilt.""" diff --git a/homeassistant/components/soma/sensor.py b/homeassistant/components/soma/sensor.py index 6472f6934e00dc..d1c0de188a08e4 100644 --- a/homeassistant/components/soma/sensor.py +++ b/homeassistant/components/soma/sensor.py @@ -43,11 +43,12 @@ def native_value(self): async def async_update(self) -> None: """Update the sensor with the latest data.""" response = await self.get_battery_level_from_api() - - # https://support.somasmarthome.com/hc/en-us/articles/360026064234-HTTP-API - # battery_level response is expected to be min = 360, max 410 for - # 0-100% levels above 410 are consider 100% and below 360, 0% as the - # device considers 360 the minimum to move the motor. - _battery = round(2 * (response["battery_level"] - 360)) + _battery = response.get("battery_percentage") + if _battery is None: + # https://support.somasmarthome.com/hc/en-us/articles/360026064234-HTTP-API + # battery_level response is expected to be min = 360, max 410 for + # 0-100% levels above 410 are consider 100% and below 360, 0% as the + # device considers 360 the minimum to move the motor. + _battery = round(2 * (response["battery_level"] - 360)) battery = max(min(100, _battery), 0) self.battery_state = battery diff --git a/homeassistant/components/sonarr/entity.py b/homeassistant/components/sonarr/entity.py index d73b9d852c86d5..6231ca3903ae40 100644 --- a/homeassistant/components/sonarr/entity.py +++ b/homeassistant/components/sonarr/entity.py @@ -24,15 +24,11 @@ def __init__( self.coordinator = coordinator self.entity_description = description self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{description.key}" - - @property - def device_info(self) -> DeviceInfo: - """Return device information about the application.""" - return DeviceInfo( - configuration_url=self.coordinator.host_configuration.base_url, + self._attr_device_info = DeviceInfo( + configuration_url=coordinator.host_configuration.base_url, entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, self.coordinator.config_entry.entry_id)}, + identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, manufacturer=DEFAULT_NAME, name=DEFAULT_NAME, - sw_version=self.coordinator.system_version, + sw_version=coordinator.system_version, ) diff --git a/homeassistant/components/songpal/media_player.py b/homeassistant/components/songpal/media_player.py index 2d2c5892636839..79fab9a26517d8 100644 --- a/homeassistant/components/songpal/media_player.py +++ b/homeassistant/components/songpal/media_player.py @@ -110,14 +110,14 @@ def __init__(self, name, device): self._model = None self._state = False - self._available = False + self._attr_available = False self._initialized = False self._volume_control = None self._volume_min = 0 self._volume_max = 1 self._volume = 0 - self._is_muted = False + self._attr_is_volume_muted = False self._active_source = None self._sources = {} @@ -137,7 +137,7 @@ async def async_activate_websocket(self): async def _volume_changed(volume: VolumeChange): _LOGGER.debug("Volume changed: %s", volume) self._volume = volume.volume - self._is_muted = volume.mute + self._attr_is_volume_muted = volume.mute self.async_write_ha_state() async def _source_changed(content: ContentChange): @@ -161,13 +161,13 @@ async def _try_reconnect(connect: ConnectChange): self._dev.endpoint, ) _LOGGER.debug("Disconnected: %s", connect.exception) - self._available = False + self._attr_available = False self.async_write_ha_state() # Try to reconnect forever, a successful reconnect will initialize # the websocket connection again. delay = INITIAL_RETRY_DELAY - while not self._available: + while not self._attr_available: _LOGGER.debug("Trying to reconnect in %s seconds", delay) await asyncio.sleep(delay) @@ -220,11 +220,6 @@ def device_info(self) -> DeviceInfo: sw_version=self._sysinfo.version, ) - @property - def available(self): - """Return availability of the device.""" - return self._available - async def async_set_sound_setting(self, name, value): """Change a setting on the device.""" _LOGGER.debug("Calling set_sound_setting with %s: %s", name, value) @@ -243,7 +238,7 @@ async def async_update(self) -> None: volumes = await self._dev.get_volume_information() if not volumes: _LOGGER.error("Got no volume controls, bailing out") - self._available = False + self._attr_available = False return if len(volumes) > 1: @@ -256,7 +251,7 @@ async def async_update(self) -> None: self._volume_min = volume.minVolume self._volume = volume.volume self._volume_control = volume - self._is_muted = self._volume_control.is_muted + self._attr_is_volume_muted = self._volume_control.is_muted status = await self._dev.get_power() self._state = status.status @@ -273,11 +268,11 @@ async def async_update(self) -> None: _LOGGER.debug("Active source: %s", self._active_source) - self._available = True + self._attr_available = True except SongpalException as ex: _LOGGER.error("Unable to update: %s", ex) - self._available = False + self._attr_available = False async def async_select_source(self, source: str) -> None: """Select source.""" @@ -309,8 +304,7 @@ def source(self): @property def volume_level(self): """Return volume level.""" - volume = self._volume / self._volume_max - return volume + return self._volume / self._volume_max async def async_set_volume_level(self, volume: float) -> None: """Set volume level.""" @@ -354,8 +348,3 @@ async def async_mute_volume(self, mute: bool) -> None: """Mute or unmute the device.""" _LOGGER.debug("Set mute: %s", mute) return await self._volume_control.set_mute(mute) - - @property - def is_volume_muted(self): - """Return whether the device is muted.""" - return self._is_muted diff --git a/homeassistant/components/soundtouch/media_player.py b/homeassistant/components/soundtouch/media_player.py index 63e5a551745208..fa5c0dd70950a6 100644 --- a/homeassistant/components/soundtouch/media_player.py +++ b/homeassistant/components/soundtouch/media_player.py @@ -78,21 +78,25 @@ class SoundTouchMediaPlayer(MediaPlayerEntity): _attr_device_class = MediaPlayerDeviceClass.SPEAKER _attr_has_entity_name = True _attr_name = None + _attr_source_list = [ + Source.AUX.value, + Source.BLUETOOTH.value, + ] def __init__(self, device: SoundTouchDevice) -> None: """Create SoundTouch media player entity.""" self._device = device - self._attr_unique_id = self._device.config.device_id + self._attr_unique_id = device.config.device_id self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self._device.config.device_id)}, + identifiers={(DOMAIN, device.config.device_id)}, connections={ - (CONNECTION_NETWORK_MAC, format_mac(self._device.config.mac_address)) + (CONNECTION_NETWORK_MAC, format_mac(device.config.mac_address)) }, manufacturer="Bose Corporation", - model=self._device.config.type, - name=self._device.config.name, + model=device.config.type, + name=device.config.name, ) self._status = None @@ -131,14 +135,6 @@ def source(self): """Name of the current input source.""" return self._status.source - @property - def source_list(self): - """List of available input sources.""" - return [ - Source.AUX.value, - Source.BLUETOOTH.value, - ] - @property def is_volume_muted(self): """Boolean if volume is currently muted.""" diff --git a/homeassistant/components/spc/alarm_control_panel.py b/homeassistant/components/spc/alarm_control_panel.py index b78703666bc304..ace352b2ba06c0 100644 --- a/homeassistant/components/spc/alarm_control_panel.py +++ b/homeassistant/components/spc/alarm_control_panel.py @@ -64,6 +64,7 @@ def __init__(self, area: Area, api: SpcWebGateway) -> None: """Initialize the SPC alarm panel.""" self._area = area self._api = api + self._attr_name = area.name async def async_added_to_hass(self) -> None: """Call for adding new entities.""" @@ -80,11 +81,6 @@ def _update_callback(self) -> None: """Call update method.""" self.async_schedule_update_ha_state(True) - @property - def name(self) -> str: - """Return the name of the device.""" - return self._area.name - @property def changed_by(self) -> str: """Return the user the last change was triggered by.""" diff --git a/homeassistant/components/spc/binary_sensor.py b/homeassistant/components/spc/binary_sensor.py index c4aaefdd5180b6..a43551567e6fb8 100644 --- a/homeassistant/components/spc/binary_sensor.py +++ b/homeassistant/components/spc/binary_sensor.py @@ -53,6 +53,8 @@ class SpcBinarySensor(BinarySensorEntity): def __init__(self, zone: Zone) -> None: """Initialize the sensor device.""" self._zone = zone + self._attr_name = zone.name + self._attr_device_class = _get_device_class(zone.type) async def async_added_to_hass(self) -> None: """Call for adding new entities.""" @@ -69,17 +71,7 @@ def _update_callback(self) -> None: """Call update method.""" self.async_schedule_update_ha_state(True) - @property - def name(self) -> str: - """Return the name of the device.""" - return self._zone.name - @property def is_on(self) -> bool: """Whether the device is switched on.""" return self._zone.input == ZoneInput.OPEN - - @property - def device_class(self) -> BinarySensorDeviceClass | None: - """Return the device class.""" - return _get_device_class(self._zone.type) diff --git a/homeassistant/components/speedtestdotnet/sensor.py b/homeassistant/components/speedtestdotnet/sensor.py index 5bcf178f3963d1..af41c400e0b671 100644 --- a/homeassistant/components/speedtestdotnet/sensor.py +++ b/homeassistant/components/speedtestdotnet/sensor.py @@ -6,6 +6,7 @@ from typing import Any, cast from homeassistant.components.sensor import ( + SensorDeviceClass, SensorEntity, SensorEntityDescription, SensorStateClass, @@ -15,7 +16,6 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -46,12 +46,14 @@ class SpeedtestSensorEntityDescription(SensorEntityDescription): translation_key="ping", native_unit_of_measurement=UnitOfTime.MILLISECONDS, state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.DURATION, ), SpeedtestSensorEntityDescription( key="download", translation_key="download", native_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND, state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.DATA_RATE, value=lambda value: round(value / 10**6, 2), ), SpeedtestSensorEntityDescription( @@ -59,6 +61,7 @@ class SpeedtestSensorEntityDescription(SensorEntityDescription): translation_key="upload", native_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND, state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.DATA_RATE, value=lambda value: round(value / 10**6, 2), ), ) @@ -77,10 +80,7 @@ async def async_setup_entry( ) -# pylint: disable-next=hass-invalid-inheritance # needs fixing -class SpeedtestSensor( - CoordinatorEntity[SpeedTestDataCoordinator], RestoreEntity, SensorEntity -): +class SpeedtestSensor(CoordinatorEntity[SpeedTestDataCoordinator], SensorEntity): """Implementation of a speedtest.net sensor.""" entity_description: SpeedtestSensorEntityDescription @@ -134,9 +134,3 @@ def extra_state_attributes(self) -> dict[str, Any]: self._attrs[ATTR_BYTES_SENT] = self.coordinator.data[ATTR_BYTES_SENT] return self._attrs - - 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(): - self._state = state.state diff --git a/homeassistant/components/spotify/manifest.json b/homeassistant/components/spotify/manifest.json index 7ca1533744c4d0..84f2bc102e3b66 100644 --- a/homeassistant/components/spotify/manifest.json +++ b/homeassistant/components/spotify/manifest.json @@ -1,7 +1,7 @@ { "domain": "spotify", "name": "Spotify", - "codeowners": ["@frenck"], + "codeowners": ["@frenck", "@joostlek"], "config_flow": true, "dependencies": ["application_credentials"], "documentation": "https://www.home-assistant.io/integrations/spotify", diff --git a/homeassistant/components/sql/manifest.json b/homeassistant/components/sql/manifest.json index 44de8fc6923611..7424807c804afe 100644 --- a/homeassistant/components/sql/manifest.json +++ b/homeassistant/components/sql/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sql", "iot_class": "local_polling", - "requirements": ["SQLAlchemy==2.0.15"] + "requirements": ["SQLAlchemy==2.0.21"] } diff --git a/homeassistant/components/sql/sensor.py b/homeassistant/components/sql/sensor.py index f4f44d4f9a4fb2..3fdc6b2c0794aa 100644 --- a/homeassistant/components/sql/sensor.py +++ b/homeassistant/components/sql/sensor.py @@ -123,7 +123,7 @@ async def async_setup_entry( value_template.hass = hass name_template = Template(name, hass) - trigger_entity_config = {CONF_NAME: name_template} + trigger_entity_config = {CONF_NAME: name_template, CONF_UNIQUE_ID: entry.entry_id} for key in TRIGGER_ENTITY_OPTIONS: if key not in entry.options: continue diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index c77126e4377869..03457c6a5c01e8 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -1,6 +1,7 @@ """Support for interfacing to the Logitech SqueezeBox API.""" from __future__ import annotations +from datetime import datetime import json import logging from typing import Any @@ -238,17 +239,17 @@ class SqueezeBoxEntity(MediaPlayerEntity): ) _attr_has_entity_name = True _attr_name = None + _last_update: datetime | None = None + _attr_available = True def __init__(self, player): """Initialize the SqueezeBox device.""" self._player = player - self._last_update = None self._query_result = {} - self._available = True self._remove_dispatcher = None - self._attr_unique_id = format_mac(self._player.player_id) + self._attr_unique_id = format_mac(player.player_id) self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self._attr_unique_id)}, name=self._player.name + identifiers={(DOMAIN, self._attr_unique_id)}, name=player.name ) @property @@ -262,16 +263,11 @@ def extra_state_attributes(self): return squeezebox_attr - @property - def available(self): - """Return True if device connected to LMS server.""" - return self._available - @callback def rediscovered(self, unique_id, connected): """Make a player available again.""" if unique_id == self.unique_id and connected: - self._available = True + self._attr_available = True _LOGGER.info("Player %s is available again", self.name) self._remove_dispatcher() @@ -287,14 +283,14 @@ def state(self) -> MediaPlayerState | None: async def async_update(self) -> None: """Update the Player() object.""" # only update available players, newly available players will be rediscovered and marked available - if self._available: + if self._attr_available: last_media_position = self.media_position await self._player.async_update() if self.media_position != last_media_position: self._last_update = utcnow() if self._player.connected is False: _LOGGER.info("Player %s is not available", self.name) - self._available = False + self._attr_available = False # start listening for restored players self._remove_dispatcher = async_dispatcher_connect( diff --git a/homeassistant/components/srp_energy/sensor.py b/homeassistant/components/srp_energy/sensor.py index a7f0f97b636587..f6bd470df8a785 100644 --- a/homeassistant/components/srp_energy/sensor.py +++ b/homeassistant/components/srp_energy/sensor.py @@ -40,12 +40,7 @@ def __init__( """Initialize the SrpEntity class.""" super().__init__(coordinator) self._attr_unique_id = f"{config_entry.entry_id}_total_usage" - self._name = SENSOR_NAME - - @property - def name(self) -> str: - """Return the name of the sensor.""" - return f"{DEFAULT_NAME} {self._name}" + self._attr_name = f"{DEFAULT_NAME} {SENSOR_NAME}" @property def native_value(self) -> float: diff --git a/homeassistant/components/ssdp/__init__.py b/homeassistant/components/ssdp/__init__.py index 3be5475a71abfa..ded663af897687 100644 --- a/homeassistant/components/ssdp/__init__.py +++ b/homeassistant/components/ssdp/__init__.py @@ -23,13 +23,7 @@ SsdpSource, ) from async_upnp_client.description_cache import DescriptionCache -from async_upnp_client.server import ( - SSDP_SEARCH_RESPONDER_OPTION_ALWAYS_REPLY_WITH_ROOT_DEVICE, - SSDP_SEARCH_RESPONDER_OPTIONS, - UpnpServer, - UpnpServerDevice, - UpnpServerService, -) +from async_upnp_client.server import UpnpServer, UpnpServerDevice, UpnpServerService from async_upnp_client.ssdp import ( SSDP_PORT, determine_source_target, @@ -63,7 +57,7 @@ UPNP_SERVER = "server" UPNP_SERVER_MIN_PORT = 40000 UPNP_SERVER_MAX_PORT = 40100 -SCAN_INTERVAL = timedelta(minutes=2) +SCAN_INTERVAL = timedelta(minutes=10) IPV4_BROADCAST = IPv4Address("255.255.255.255") @@ -606,7 +600,7 @@ def discovery_info_from_headers_and_description( ) -> SsdpServiceInfo: """Convert headers and description to discovery_info.""" ssdp_usn = combined_headers["usn"] - ssdp_st = combined_headers.get("st") + ssdp_st = combined_headers.get_lower("st") if isinstance(info_desc, CaseInsensitiveDict): upnp_info = {**info_desc.as_dict()} else: @@ -626,11 +620,11 @@ def discovery_info_from_headers_and_description( return SsdpServiceInfo( ssdp_usn=ssdp_usn, ssdp_st=ssdp_st, - ssdp_ext=combined_headers.get("ext"), - ssdp_server=combined_headers.get("server"), - ssdp_location=combined_headers.get("location"), - ssdp_udn=combined_headers.get("_udn"), - ssdp_nt=combined_headers.get("nt"), + ssdp_ext=combined_headers.get_lower("ext"), + ssdp_server=combined_headers.get_lower("server"), + ssdp_location=combined_headers.get_lower("location"), + ssdp_udn=combined_headers.get_lower("_udn"), + ssdp_nt=combined_headers.get_lower("nt"), ssdp_headers=combined_headers, upnp=upnp_info, ) @@ -796,11 +790,6 @@ async def _async_start_upnp_servers(self, event: Event) -> None: http_port=http_port, server_device=HassUpnpServiceDevice, boot_id=boot_id, - options={ - SSDP_SEARCH_RESPONDER_OPTIONS: { - SSDP_SEARCH_RESPONDER_OPTION_ALWAYS_REPLY_WITH_ROOT_DEVICE: True - } - }, ) ) results = await asyncio.gather( diff --git a/homeassistant/components/ssdp/manifest.json b/homeassistant/components/ssdp/manifest.json index a6eb95933b4b64..c9cf452bac2b71 100644 --- a/homeassistant/components/ssdp/manifest.json +++ b/homeassistant/components/ssdp/manifest.json @@ -9,5 +9,5 @@ "iot_class": "local_push", "loggers": ["async_upnp_client"], "quality_scale": "internal", - "requirements": ["async-upnp-client==0.35.0"] + "requirements": ["async-upnp-client==0.35.1"] } diff --git a/homeassistant/components/starline/entity.py b/homeassistant/components/starline/entity.py index 7eee5e7a7f8a98..27be5e2aaced42 100644 --- a/homeassistant/components/starline/entity.py +++ b/homeassistant/components/starline/entity.py @@ -21,6 +21,8 @@ def __init__( self._account = account self._device = device self._key = key + self._attr_unique_id = f"starline-{key}-{device.device_id}" + self._attr_device_info = account.device_info(device) self._unsubscribe_api: Callable | None = None @property @@ -28,16 +30,6 @@ def available(self): """Return True if entity is available.""" return self._account.api.available - @property - def unique_id(self): - """Return the unique ID of the entity.""" - return f"starline-{self._key}-{self._device.device_id}" - - @property - def device_info(self): - """Return the device info.""" - return self._account.device_info(self._device) - def update(self): """Read new state data.""" self.schedule_update_ha_state() diff --git a/homeassistant/components/starline/switch.py b/homeassistant/components/starline/switch.py index b254fa8133fc92..ebe27e29e8c230 100644 --- a/homeassistant/components/starline/switch.py +++ b/homeassistant/components/starline/switch.py @@ -77,6 +77,8 @@ class StarlineSwitch(StarlineEntity, SwitchEntity): entity_description: StarlineSwitchEntityDescription + _attr_assumed_state = True + def __init__( self, account: StarlineAccount, @@ -108,11 +110,6 @@ def icon(self): else self.entity_description.icon_off ) - @property - def assumed_state(self): - """Return True if unable to access real state of the entity.""" - return True - @property def is_on(self): """Return True if entity is on.""" diff --git a/homeassistant/components/stream/__init__.py b/homeassistant/components/stream/__init__.py index 691ba262ee2371..626a03b785f4be 100644 --- a/homeassistant/components/stream/__init__.py +++ b/homeassistant/components/stream/__init__.py @@ -29,6 +29,7 @@ import voluptuous as vol from yarl import URL +from homeassistant.components.logger import EVENT_LOGGING_CHANGED from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError @@ -188,36 +189,32 @@ def convert_stream_options( ) -def filter_libav_logging() -> None: - """Filter libav logging to only log when the stream logger is at DEBUG.""" +@callback +def update_pyav_logging(_event: Event | None = None) -> None: + """Adjust libav logging to only log when the stream logger is at DEBUG.""" - def libav_filter(record: logging.LogRecord) -> bool: - return logging.getLogger(__name__).isEnabledFor(logging.DEBUG) + def set_pyav_logging(enable: bool) -> None: + """Turn PyAV logging on or off.""" + import av # pylint: disable=import-outside-toplevel - for logging_namespace in ( - "libav.NULL", - "libav.h264", - "libav.hevc", - "libav.hls", - "libav.mp4", - "libav.mpegts", - "libav.rtsp", - "libav.tcp", - "libav.tls", - ): - logging.getLogger(logging_namespace).addFilter(libav_filter) + av.logging.set_level(av.logging.VERBOSE if enable else av.logging.FATAL) - # Set log level to error for libav.mp4 - logging.getLogger("libav.mp4").setLevel(logging.ERROR) - # Suppress "deprecated pixel format" WARNING - logging.getLogger("libav.swscaler").setLevel(logging.ERROR) + # enable PyAV logging iff Stream logger is set to debug + set_pyav_logging(logging.getLogger(__name__).isEnabledFor(logging.DEBUG)) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up stream.""" - # Drop libav log messages if stream logging is above DEBUG - filter_libav_logging() + # Only pass through PyAV log messages if stream logging is above DEBUG + cancel_logging_listener = hass.bus.async_listen( + EVENT_LOGGING_CHANGED, update_pyav_logging + ) + # libav.mp4 and libav.swscaler have a few unimportant messages that are logged + # at logging.WARNING. Set those Logger levels to logging.ERROR + for logging_namespace in ("libav.mp4", "libav.swscaler"): + logging.getLogger(logging_namespace).setLevel(logging.ERROR) + update_pyav_logging() # Keep import here so that we can import stream integration without installing reqs # pylint: disable-next=import-outside-toplevel @@ -258,6 +255,7 @@ async def shutdown(event: Event) -> None: ]: await asyncio.wait(awaitables) _LOGGER.debug("Stopped stream workers") + cancel_logging_listener() hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, shutdown) diff --git a/homeassistant/components/stream/manifest.json b/homeassistant/components/stream/manifest.json index 96474ceb7eb7b9..45e9a96d7590ef 100644 --- a/homeassistant/components/stream/manifest.json +++ b/homeassistant/components/stream/manifest.json @@ -2,10 +2,10 @@ "domain": "stream", "name": "Stream", "codeowners": ["@hunterjm", "@uvjustin", "@allenporter"], - "dependencies": ["http"], + "dependencies": ["http", "logger"], "documentation": "https://www.home-assistant.io/integrations/stream", "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["PyTurboJPEG==1.7.1", "ha-av==10.1.1", "numpy==1.23.2"] + "requirements": ["PyTurboJPEG==1.7.1", "ha-av==10.1.1", "numpy==1.26.0"] } diff --git a/homeassistant/components/stt/__init__.py b/homeassistant/components/stt/__init__.py index 679f9b29e41430..b1730a09357dd3 100644 --- a/homeassistant/components/stt/__init__.py +++ b/homeassistant/components/stt/__init__.py @@ -40,12 +40,11 @@ ) from .legacy import ( Provider, - SpeechMetadata, - SpeechResult, async_default_provider, async_get_provider, async_setup_legacy, ) +from .models import SpeechMetadata, SpeechResult __all__ = [ "async_get_provider", diff --git a/homeassistant/components/stt/legacy.py b/homeassistant/components/stt/legacy.py index f14eed467db2e7..862f59d5f6d97f 100644 --- a/homeassistant/components/stt/legacy.py +++ b/homeassistant/components/stt/legacy.py @@ -3,7 +3,6 @@ from abc import ABC, abstractmethod from collections.abc import AsyncIterable, Coroutine -from dataclasses import dataclass import logging from typing import Any @@ -20,8 +19,8 @@ AudioCodecs, AudioFormats, AudioSampleRates, - SpeechResultState, ) +from .models import SpeechMetadata, SpeechResult _LOGGER = logging.getLogger(__name__) @@ -88,32 +87,6 @@ async def async_platform_discovered(platform, info): ] -@dataclass -class SpeechMetadata: - """Metadata of audio stream.""" - - language: str - format: AudioFormats - codec: AudioCodecs - bit_rate: AudioBitRates - sample_rate: AudioSampleRates - channel: AudioChannels - - def __post_init__(self) -> None: - """Finish initializing the metadata.""" - self.bit_rate = AudioBitRates(int(self.bit_rate)) - self.sample_rate = AudioSampleRates(int(self.sample_rate)) - self.channel = AudioChannels(int(self.channel)) - - -@dataclass -class SpeechResult: - """Result of audio Speech.""" - - text: str | None - result: SpeechResultState - - class Provider(ABC): """Represent a single STT provider.""" diff --git a/homeassistant/components/stt/models.py b/homeassistant/components/stt/models.py new file mode 100644 index 00000000000000..45322e2da079fc --- /dev/null +++ b/homeassistant/components/stt/models.py @@ -0,0 +1,37 @@ +"""Speech-to-text data models.""" +from dataclasses import dataclass + +from .const import ( + AudioBitRates, + AudioChannels, + AudioCodecs, + AudioFormats, + AudioSampleRates, + SpeechResultState, +) + + +@dataclass +class SpeechMetadata: + """Metadata of audio stream.""" + + language: str + format: AudioFormats + codec: AudioCodecs + bit_rate: AudioBitRates + sample_rate: AudioSampleRates + channel: AudioChannels + + def __post_init__(self) -> None: + """Finish initializing the metadata.""" + self.bit_rate = AudioBitRates(int(self.bit_rate)) + self.sample_rate = AudioSampleRates(int(self.sample_rate)) + self.channel = AudioChannels(int(self.channel)) + + +@dataclass +class SpeechResult: + """Result of audio Speech.""" + + text: str | None + result: SpeechResultState diff --git a/homeassistant/components/subaru/strings.json b/homeassistant/components/subaru/strings.json index 5e6db32d4add11..78625192e4a58f 100644 --- a/homeassistant/components/subaru/strings.json +++ b/homeassistant/components/subaru/strings.json @@ -28,7 +28,7 @@ "title": "[%key:component::subaru::config::step::user::title%]", "description": "Please enter your MySubaru PIN\nNOTE: All vehicles in account must have the same PIN", "data": { - "pin": "PIN" + "pin": "[%key:common::config_flow::data::pin%]" } } }, diff --git a/homeassistant/components/sun/__init__.py b/homeassistant/components/sun/__init__.py index de1c545739f3f0..5bb105f8123c24 100644 --- a/homeassistant/components/sun/__init__.py +++ b/homeassistant/components/sun/__init__.py @@ -17,9 +17,6 @@ from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback from homeassistant.helpers import config_validation as cv, event from homeassistant.helpers.entity import Entity -from homeassistant.helpers.integration_platform import ( - async_process_integration_platform_for_component, -) from homeassistant.helpers.sun import ( get_astral_location, get_location_astral_event_next, @@ -97,9 +94,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up from a config entry.""" - # Process integration platforms right away since - # we will create entities before firing EVENT_COMPONENT_LOADED - await async_process_integration_platform_for_component(hass, DOMAIN) hass.data[DOMAIN] = Sun(hass) await hass.config_entries.async_forward_entry_setups(entry, [Platform.SENSOR]) return True @@ -119,6 +113,20 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: class Sun(Entity): """Representation of the Sun.""" + _unrecorded_attributes = frozenset( + { + STATE_ATTR_AZIMUTH, + STATE_ATTR_ELEVATION, + STATE_ATTR_RISING, + STATE_ATTR_NEXT_DAWN, + STATE_ATTR_NEXT_DUSK, + STATE_ATTR_NEXT_MIDNIGHT, + STATE_ATTR_NEXT_NOON, + STATE_ATTR_NEXT_RISING, + STATE_ATTR_NEXT_SETTING, + } + ) + _attr_name = "Sun" entity_id = ENTITY_ID # This entity is legacy and does not have a platform. @@ -143,6 +151,12 @@ def __init__(self, hass: HomeAssistant) -> None: self.hass = hass self.phase: str | None = None + # This is normally done by async_internal_added_to_hass which is not called + # for sun because sun has no platform + self._state_info = { + "unrecorded_attributes": self._Entity__combined_unrecorded_attributes # type: ignore[attr-defined] + } + self._config_listener: CALLBACK_TYPE | None = None self._update_events_listener: CALLBACK_TYPE | None = None self._update_sun_position_listener: CALLBACK_TYPE | None = None diff --git a/homeassistant/components/sun/recorder.py b/homeassistant/components/sun/recorder.py deleted file mode 100644 index 710d7ff45594da..00000000000000 --- a/homeassistant/components/sun/recorder.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.core import HomeAssistant, callback - -from . import ( - STATE_ATTR_AZIMUTH, - STATE_ATTR_ELEVATION, - STATE_ATTR_NEXT_DAWN, - STATE_ATTR_NEXT_DUSK, - STATE_ATTR_NEXT_MIDNIGHT, - STATE_ATTR_NEXT_NOON, - STATE_ATTR_NEXT_RISING, - STATE_ATTR_NEXT_SETTING, - STATE_ATTR_RISING, -) - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude sun attributes from being recorded in the database.""" - return { - STATE_ATTR_AZIMUTH, - STATE_ATTR_ELEVATION, - STATE_ATTR_RISING, - STATE_ATTR_NEXT_DAWN, - STATE_ATTR_NEXT_DUSK, - STATE_ATTR_NEXT_MIDNIGHT, - STATE_ATTR_NEXT_NOON, - STATE_ATTR_NEXT_RISING, - STATE_ATTR_NEXT_SETTING, - } diff --git a/homeassistant/components/supla/cover.py b/homeassistant/components/supla/cover.py index 53e57fe18540eb..cc3a5a4ed0ca10 100644 --- a/homeassistant/components/supla/cover.py +++ b/homeassistant/components/supla/cover.py @@ -95,6 +95,8 @@ async def async_stop_cover(self, **kwargs: Any) -> None: class SuplaDoorEntity(SuplaEntity, CoverEntity): """Representation of a Supla door.""" + _attr_device_class = CoverDeviceClass.GARAGE + @property def is_closed(self) -> bool | None: """Return if the door is closed or not.""" @@ -120,8 +122,3 @@ async def async_stop_cover(self, **kwargs: Any) -> None: async def async_toggle(self, **kwargs: Any) -> None: """Toggle the door.""" await self.async_action("OPEN_CLOSE") - - @property - def device_class(self) -> CoverDeviceClass: - """Return the class of this device, from component DEVICE_CLASSES.""" - return CoverDeviceClass.GARAGE diff --git a/homeassistant/components/switchbot/manifest.json b/homeassistant/components/switchbot/manifest.json index 2259a450559114..49a6af2b179a21 100644 --- a/homeassistant/components/switchbot/manifest.json +++ b/homeassistant/components/switchbot/manifest.json @@ -1,6 +1,6 @@ { "domain": "switchbot", - "name": "SwitchBot", + "name": "SwitchBot Bluetooth", "bluetooth": [ { "service_data_uuid": "00000d00-0000-1000-8000-00805f9b34fb", diff --git a/homeassistant/components/switchbot_cloud/__init__.py b/homeassistant/components/switchbot_cloud/__init__.py new file mode 100644 index 00000000000000..cf711fcc4311ce --- /dev/null +++ b/homeassistant/components/switchbot_cloud/__init__.py @@ -0,0 +1,81 @@ +"""The SwitchBot via API integration.""" +from asyncio import gather +from dataclasses import dataclass +from logging import getLogger + +from switchbot_api import CannotConnect, Device, InvalidAuth, Remote, SwitchBotAPI + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import DOMAIN +from .coordinator import SwitchBotCoordinator + +_LOGGER = getLogger(__name__) +PLATFORMS: list[Platform] = [Platform.SWITCH] + + +@dataclass +class SwitchbotDevices: + """Switchbot devices data.""" + + switches: list[Device | Remote] + + +@dataclass +class SwitchbotCloudData: + """Data to use in platforms.""" + + api: SwitchBotAPI + devices: SwitchbotDevices + + +async def async_setup_entry(hass: HomeAssistant, config: ConfigEntry) -> bool: + """Set up SwitchBot via API from a config entry.""" + token = config.data[CONF_API_TOKEN] + secret = config.data[CONF_API_KEY] + + api = SwitchBotAPI(token=token, secret=secret) + try: + devices = await api.list_devices() + except InvalidAuth as ex: + _LOGGER.error( + "Invalid authentication while connecting to SwitchBot API: %s", ex + ) + return False + except CannotConnect as ex: + raise ConfigEntryNotReady from ex + _LOGGER.debug("Devices: %s", devices) + devices_and_coordinators = [ + (device, SwitchBotCoordinator(hass, api, device)) for device in devices + ] + hass.data.setdefault(DOMAIN, {}) + data = SwitchbotCloudData( + api=api, + devices=SwitchbotDevices( + switches=[ + (device, coordinator) + for device, coordinator in devices_and_coordinators + if isinstance(device, Device) + and device.device_type.startswith("Plug") + or isinstance(device, Remote) + ], + ), + ) + hass.data[DOMAIN][config.entry_id] = data + _LOGGER.debug("Switches: %s", data.devices.switches) + await hass.config_entries.async_forward_entry_setups(config, PLATFORMS) + await gather( + *[coordinator.async_refresh() for _, coordinator in devices_and_coordinators] + ) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> 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 diff --git a/homeassistant/components/switchbot_cloud/config_flow.py b/homeassistant/components/switchbot_cloud/config_flow.py new file mode 100644 index 00000000000000..5c99567968c1e6 --- /dev/null +++ b/homeassistant/components/switchbot_cloud/config_flow.py @@ -0,0 +1,56 @@ +"""Config flow for SwitchBot via API integration.""" + +from logging import getLogger +from typing import Any + +from switchbot_api import CannotConnect, InvalidAuth, SwitchBotAPI +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN, ENTRY_TITLE + +_LOGGER = getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_API_TOKEN): str, + vol.Required(CONF_API_KEY): str, + } +) + + +class SwitchBotCloudConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for SwitchBot via API.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + try: + await SwitchBotAPI( + token=user_input[CONF_API_TOKEN], secret=user_input[CONF_API_KEY] + ).list_devices() + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id( + user_input[CONF_API_TOKEN], raise_on_progress=False + ) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=ENTRY_TITLE, data=user_input) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/switchbot_cloud/const.py b/homeassistant/components/switchbot_cloud/const.py new file mode 100644 index 00000000000000..ef69c9c1d02d29 --- /dev/null +++ b/homeassistant/components/switchbot_cloud/const.py @@ -0,0 +1,7 @@ +"""Constants for the SwitchBot Cloud integration.""" +from datetime import timedelta +from typing import Final + +DOMAIN: Final = "switchbot_cloud" +ENTRY_TITLE = "SwitchBot Cloud" +SCAN_INTERVAL = timedelta(seconds=600) diff --git a/homeassistant/components/switchbot_cloud/coordinator.py b/homeassistant/components/switchbot_cloud/coordinator.py new file mode 100644 index 00000000000000..92099ccde4337b --- /dev/null +++ b/homeassistant/components/switchbot_cloud/coordinator.py @@ -0,0 +1,50 @@ +"""SwitchBot Cloud coordinator.""" +from asyncio import timeout +from logging import getLogger +from typing import Any + +from switchbot_api import CannotConnect, Device, Remote, SwitchBotAPI + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, SCAN_INTERVAL + +_LOGGER = getLogger(__name__) + +Status = dict[str, Any] | None + + +class SwitchBotCoordinator(DataUpdateCoordinator[Status]): + """SwitchBot Cloud coordinator.""" + + _api: SwitchBotAPI + _device_id: str + _should_poll = False + + def __init__( + self, hass: HomeAssistant, api: SwitchBotAPI, device: Device | Remote + ) -> None: + """Initialize SwitchBot Cloud.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + self._api = api + self._device_id = device.device_id + self._should_poll = not isinstance(device, Remote) + + async def _async_update_data(self) -> Status: + """Fetch data from API endpoint.""" + if not self._should_poll: + return None + try: + _LOGGER.debug("Refreshing %s", self._device_id) + async with timeout(10): + 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: + raise UpdateFailed(f"Error communicating with API: {err}") from err diff --git a/homeassistant/components/switchbot_cloud/entity.py b/homeassistant/components/switchbot_cloud/entity.py new file mode 100644 index 00000000000000..5d0e2ff09c34c4 --- /dev/null +++ b/homeassistant/components/switchbot_cloud/entity.py @@ -0,0 +1,49 @@ +"""Base class for SwitchBot via API entities.""" +from typing import Any + +from switchbot_api import Commands, Device, Remote, SwitchBotAPI + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import SwitchBotCoordinator + + +class SwitchBotCloudEntity(CoordinatorEntity[SwitchBotCoordinator]): + """Representation of a SwitchBot Cloud entity.""" + + _api: SwitchBotAPI + _switchbot_state: dict[str, Any] | None = None + _attr_has_entity_name = True + + def __init__( + self, + api: SwitchBotAPI, + device: Device | Remote, + coordinator: SwitchBotCoordinator, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self._api = api + self._attr_unique_id = device.device_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device.device_id)}, + name=device.device_name, + manufacturer="SwitchBot", + model=device.device_type, + ) + + async def send_command( + self, + command: Commands, + command_type: str = "command", + parameters: dict | str = "default", + ) -> None: + """Send command to device.""" + await self._api.send_command( + self._attr_unique_id, + command, + command_type, + parameters, + ) diff --git a/homeassistant/components/switchbot_cloud/manifest.json b/homeassistant/components/switchbot_cloud/manifest.json new file mode 100644 index 00000000000000..0451217ca5f34d --- /dev/null +++ b/homeassistant/components/switchbot_cloud/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "switchbot_cloud", + "name": "SwitchBot Cloud", + "codeowners": ["@SeraphicRav"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/switchbot_cloud", + "iot_class": "cloud_polling", + "loggers": ["switchbot-api"], + "requirements": ["switchbot-api==1.1.0"] +} diff --git a/homeassistant/components/switchbot_cloud/strings.json b/homeassistant/components/switchbot_cloud/strings.json new file mode 100644 index 00000000000000..11e92e6dfa38f9 --- /dev/null +++ b/homeassistant/components/switchbot_cloud/strings.json @@ -0,0 +1,20 @@ +{ + "config": { + "step": { + "user": { + "data": { + "api_token": "[%key:common::config_flow::data::api_token%]", + "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_device%]" + } + } +} diff --git a/homeassistant/components/switchbot_cloud/switch.py b/homeassistant/components/switchbot_cloud/switch.py new file mode 100644 index 00000000000000..c63b1713b8de6c --- /dev/null +++ b/homeassistant/components/switchbot_cloud/switch.py @@ -0,0 +1,82 @@ +"""Support for SwitchBot switch.""" +from typing import Any + +from switchbot_api import CommonCommands, Device, PowerState, Remote, SwitchBotAPI + +from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import DiscoveryInfoType + +from . import SwitchbotCloudData +from .const import DOMAIN +from .coordinator import SwitchBotCoordinator +from .entity import SwitchBotCloudEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config: ConfigEntry, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Set up SwitchBot Cloud entry.""" + data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id] + async_add_entities( + _async_make_entity(data.api, device, coordinator) + for device, coordinator in data.devices.switches + ) + + +class SwitchBotCloudSwitch(SwitchBotCloudEntity, SwitchEntity): + """Representation of a SwitchBot switch.""" + + _attr_device_class = SwitchDeviceClass.SWITCH + _attr_name = None + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the device on.""" + await self.send_command(CommonCommands.ON) + self._attr_is_on = True + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the device off.""" + await self.send_command(CommonCommands.OFF) + self._attr_is_on = False + self.async_write_ha_state() + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + if not self.coordinator.data: + return + self._attr_is_on = self.coordinator.data.get("power") == PowerState.ON.value + self.async_write_ha_state() + + +class SwitchBotCloudRemoteSwitch(SwitchBotCloudSwitch): + """Representation of a SwitchBot switch provider by a remote.""" + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + + +class SwitchBotCloudPlugSwitch(SwitchBotCloudSwitch): + """Representation of a SwitchBot plug switch.""" + + _attr_device_class = SwitchDeviceClass.OUTLET + + +@callback +def _async_make_entity( + api: SwitchBotAPI, device: Device | Remote, coordinator: SwitchBotCoordinator +) -> SwitchBotCloudSwitch: + """Make a SwitchBotCloudSwitch or SwitchBotCloudRemoteSwitch.""" + if isinstance(device, Remote): + return SwitchBotCloudRemoteSwitch(api, device, coordinator) + if "Plug" in device.device_type: + return SwitchBotCloudPlugSwitch(api, device, coordinator) + raise NotImplementedError(f"Unsupported device type: {device.device_type}") diff --git a/homeassistant/components/syncthing/sensor.py b/homeassistant/components/syncthing/sensor.py index 0551ae29d2c0bb..c88de91cae076e 100644 --- a/homeassistant/components/syncthing/sensor.py +++ b/homeassistant/components/syncthing/sensor.py @@ -94,19 +94,17 @@ def __init__(self, syncthing, server_id, folder_id, folder_label, version): self._folder_label = folder_label self._state = None self._unsub_timer = None - self._version = version self._short_server_id = server_id.split("-")[0] - - @property - def name(self): - """Return the name of the sensor.""" - return f"{self._short_server_id} {self._folder_id} {self._folder_label}" - - @property - def unique_id(self): - """Return the unique id of the entity.""" - return f"{self._short_server_id}-{self._folder_id}" + self._attr_name = f"{self._short_server_id} {folder_id} {folder_label}" + self._attr_unique_id = f"{self._short_server_id}-{folder_id}" + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, self._server_id)}, + manufacturer="Syncthing Team", + name=f"Syncthing ({syncthing.url})", + sw_version=version, + ) @property def native_value(self): @@ -132,17 +130,6 @@ def extra_state_attributes(self): """Return the state attributes.""" return self._state - @property - def device_info(self) -> DeviceInfo: - """Return device information.""" - return DeviceInfo( - entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, self._server_id)}, - manufacturer="Syncthing Team", - name=f"Syncthing ({self._syncthing.url})", - sw_version=self._version, - ) - async def async_update_status(self): """Request folder status and update state.""" try: diff --git a/homeassistant/components/syncthru/sensor.py b/homeassistant/components/syncthru/sensor.py index c2ad159fb21093..f651556bddb5c5 100644 --- a/homeassistant/components/syncthru/sensor.py +++ b/homeassistant/components/syncthru/sensor.py @@ -109,6 +109,8 @@ class SyncThruMainSensor(SyncThruSensor): the displayed current status message. """ + _attr_entity_registry_enabled_default = False + def __init__(self, coordinator: DataUpdateCoordinator[SyncThru], name: str) -> None: """Initialize the sensor.""" super().__init__(coordinator, name) @@ -126,11 +128,6 @@ def extra_state_attributes(self): "display_text": self.syncthru.device_status_details(), } - @property - def entity_registry_enabled_default(self) -> bool: - """Disable entity by default.""" - return False - class SyncThruTonerSensor(SyncThruSensor): """Implementation of a Samsung Printer toner sensor platform.""" diff --git a/homeassistant/components/system_bridge/__init__.py b/homeassistant/components/system_bridge/__init__.py index d50540f7b428cb..d13f5bcbdde3c0 100644 --- a/homeassistant/components/system_bridge/__init__.py +++ b/homeassistant/components/system_bridge/__init__.py @@ -20,7 +20,9 @@ from homeassistant.const import ( CONF_API_KEY, CONF_COMMAND, + CONF_ENTITY_ID, CONF_HOST, + CONF_NAME, CONF_PATH, CONF_PORT, CONF_URL, @@ -28,7 +30,11 @@ ) from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + discovery, +) from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -40,6 +46,7 @@ PLATFORMS = [ Platform.BINARY_SENSOR, + Platform.NOTIFY, Platform.SENSOR, ] @@ -142,7 +149,24 @@ async def async_setup_entry( hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][entry.entry_id] = coordinator - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + # Set up all platforms except notify + await hass.config_entries.async_forward_entry_setups( + entry, [platform for platform in PLATFORMS if platform != Platform.NOTIFY] + ) + + # Set up notify platform + hass.async_create_task( + discovery.async_load_platform( + hass, + Platform.NOTIFY, + DOMAIN, + { + CONF_NAME: f"{DOMAIN}_{coordinator.data.system.hostname}", + CONF_ENTITY_ID: entry.entry_id, + }, + hass.data[DOMAIN][entry.entry_id], + ) + ) if hass.services.has_service(DOMAIN, SERVICE_OPEN_URL): return True @@ -277,7 +301,9 @@ async def handle_send_text(call: ServiceCall) -> None: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + unload_ok = await hass.config_entries.async_unload_platforms( + entry, [platform for platform in PLATFORMS if platform != Platform.NOTIFY] + ) if unload_ok: coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][ entry.entry_id diff --git a/homeassistant/components/system_bridge/manifest.json b/homeassistant/components/system_bridge/manifest.json index c0f89c16339888..bcc6189c8ef449 100644 --- a/homeassistant/components/system_bridge/manifest.json +++ b/homeassistant/components/system_bridge/manifest.json @@ -10,6 +10,6 @@ "iot_class": "local_push", "loggers": ["systembridgeconnector"], "quality_scale": "silver", - "requirements": ["systembridgeconnector==3.4.9"], + "requirements": ["systembridgeconnector==3.8.2"], "zeroconf": ["_system-bridge._tcp.local."] } diff --git a/homeassistant/components/system_bridge/notify.py b/homeassistant/components/system_bridge/notify.py new file mode 100644 index 00000000000000..1ad071bf78f941 --- /dev/null +++ b/homeassistant/components/system_bridge/notify.py @@ -0,0 +1,76 @@ +"""Support for System Bridge notification service.""" +from __future__ import annotations + +import logging +from typing import Any + +from systembridgeconnector.models.notification import Notification + +from homeassistant.components.notify import ( + ATTR_DATA, + ATTR_TITLE, + ATTR_TITLE_DEFAULT, + BaseNotificationService, +) +from homeassistant.const import ATTR_ICON, CONF_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType + +from .const import DOMAIN +from .coordinator import SystemBridgeDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + +ATTR_ACTIONS = "actions" +ATTR_AUDIO = "audio" +ATTR_IMAGE = "image" +ATTR_TIMEOUT = "timeout" + + +async def async_get_service( + hass: HomeAssistant, + config: ConfigType, + discovery_info: DiscoveryInfoType | None = None, +) -> SystemBridgeNotificationService | None: + """Get the System Bridge notification service.""" + if discovery_info is None: + return None + + coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][ + discovery_info[CONF_ENTITY_ID] + ] + + return SystemBridgeNotificationService(coordinator) + + +class SystemBridgeNotificationService(BaseNotificationService): + """Implement the notification service for System Bridge.""" + + def __init__( + self, + coordinator: SystemBridgeDataUpdateCoordinator, + ) -> None: + """Initialize the service.""" + self._coordinator: SystemBridgeDataUpdateCoordinator = coordinator + + async def async_send_message( + self, + message: str = "", + **kwargs: Any, + ) -> None: + """Send a message.""" + data = kwargs.get(ATTR_DATA, {}) or {} + + notification = Notification( + actions=data.get(ATTR_ACTIONS), + audio=data.get(ATTR_AUDIO), + icon=data.get(ATTR_ICON), + image=data.get(ATTR_IMAGE), + message=message, + timeout=data.get(ATTR_TIMEOUT), + title=kwargs.get(ATTR_TITLE, data.get(ATTR_TITLE, ATTR_TITLE_DEFAULT)), + ) + + _LOGGER.debug("Sending notification: %s", notification.json()) + + await self._coordinator.websocket_client.send_notification(notification) diff --git a/homeassistant/components/system_log/__init__.py b/homeassistant/components/system_log/__init__.py index ab271ec676c25c..fab2b7ee2914ae 100644 --- a/homeassistant/components/system_log/__init__.py +++ b/homeassistant/components/system_log/__init__.py @@ -4,6 +4,7 @@ from collections import OrderedDict, deque import logging import re +import sys import traceback from typing import Any, cast @@ -59,31 +60,65 @@ def _figure_out_source( - record: logging.LogRecord, call_stack: list[tuple[str, int]], paths_re: re.Pattern + record: logging.LogRecord, paths_re: re.Pattern ) -> tuple[str, int]: + """Figure out where a log message came from.""" # If a stack trace exists, extract file names from the entire call stack. # The other case is when a regular "log" is made (without an attached # exception). In that case, just use the file where the log was made from. if record.exc_info: stack = [(x[0], x[1]) for x in traceback.extract_tb(record.exc_info[2])] - else: - index = -1 - for i, frame in enumerate(call_stack): - if frame[0] == record.pathname: - index = i + for i, (filename, _) in enumerate(stack): + # Slice the stack to the first frame that matches + # the record pathname. + if filename == record.pathname: + stack = stack[0 : i + 1] break - if index == -1: - # For some reason we couldn't find pathname in the stack. - stack = [(record.pathname, record.lineno)] - else: - stack = call_stack[0 : index + 1] - - # Iterate through the stack call (in reverse) and find the last call from - # a file in Home Assistant. Try to figure out where error happened. - for pathname in reversed(stack): - # Try to match with a file within Home Assistant - if match := paths_re.match(pathname[0]): - return (cast(str, match.group(1)), pathname[1]) + # Iterate through the stack call (in reverse) and find the last call from + # a file in Home Assistant. Try to figure out where error happened. + for path, line_number in reversed(stack): + # Try to match with a file within Home Assistant + if match := paths_re.match(path): + return (cast(str, match.group(1)), line_number) + else: + # + # We need to figure out where the log call came from if we + # don't have an exception. + # + # We do this by walking up the stack until we find the first + # frame match the record pathname so the code below + # can be used to reverse the remaining stack frames + # and find the first one that is from a file within Home Assistant. + # + # We do not call traceback.extract_stack() because it is + # it makes many stat() syscalls calls which do blocking I/O, + # and since this code is running in the event loop, we need to avoid + # blocking I/O. + + frame = sys._getframe(4) # pylint: disable=protected-access + # + # We use _getframe with 4 to skip the following frames: + # + # Jump 2 frames up to get to the actual caller + # since we are in a function, and always called from another function + # that are never the original source of the log message. + # + # Next try to skip any frames that are from the logging module + # We know that the logger module typically has 5 frames itself + # but it may change in the future so we are conservative and + # only skip 2. + # + # _getframe is cpython only but we are already using cpython specific + # code everywhere in HA so it's fine as its unlikely we will ever + # support other python implementations. + # + # Iterate through the stack call (in reverse) and find the last call from + # a file in Home Assistant. Try to figure out where error happened. + while back := frame.f_back: + if match := paths_re.match(frame.f_code.co_filename): + return (cast(str, match.group(1)), frame.f_lineno) + frame = back + # Ok, we don't know what this is return (record.pathname, record.lineno) @@ -217,11 +252,7 @@ def emit(self, record: logging.LogRecord) -> None: default upper limit is set to 50 (older entries are discarded) but can be changed if needed. """ - stack = [] - if not record.exc_info: - stack = [(f[0], f[1]) for f in traceback.extract_stack()] - - entry = LogEntry(record, _figure_out_source(record, stack, self.paths_re)) + entry = LogEntry(record, _figure_out_source(record, self.paths_re)) self.records.add_entry(entry) if self.fire_event: self.hass.bus.fire(EVENT_SYSTEM_LOG, entry.to_dict()) diff --git a/homeassistant/components/tado/climate.py b/homeassistant/components/tado/climate.py index 36a2ab671c9163..1193638c10e0a0 100644 --- a/homeassistant/components/tado/climate.py +++ b/homeassistant/components/tado/climate.py @@ -219,6 +219,8 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_name = None + _attr_translation_key = DOMAIN + _available = False def __init__( self, @@ -245,22 +247,22 @@ def __init__( self.zone_type = zone_type self._attr_unique_id = f"{zone_type} {zone_id} {tado.home_id}" - self._attr_temperature_unit = UnitOfTemperature.CELSIUS - - self._attr_translation_key = DOMAIN self._device_info = device_info self._device_id = self._device_info["shortSerialNo"] self._ac_device = zone_type == TYPE_AIR_CONDITIONING - self._supported_hvac_modes = supported_hvac_modes - self._supported_fan_modes = supported_fan_modes + self._attr_hvac_modes = supported_hvac_modes + self._attr_fan_modes = supported_fan_modes self._attr_supported_features = support_flags - self._available = False - self._cur_temp = None self._cur_humidity = None + if self.supported_features & ClimateEntityFeature.SWING_MODE: + self._attr_swing_modes = [ + TADO_TO_HA_SWING_MODE_MAP[TADO_SWING_ON], + TADO_TO_HA_SWING_MODE_MAP[TADO_SWING_OFF], + ] self._heat_min_temp = heat_min_temp self._heat_max_temp = heat_max_temp @@ -324,14 +326,6 @@ def hvac_mode(self) -> HVACMode: """ return TADO_TO_HA_HVAC_MODE_MAP.get(self._current_tado_hvac_mode, HVACMode.OFF) - @property - def hvac_modes(self) -> list[HVACMode]: - """Return the list of available hvac operation modes. - - Need to be a subset of HVAC_MODES. - """ - return self._supported_hvac_modes - @property def hvac_action(self) -> HVACAction: """Return the current running hvac operation if supported. @@ -349,11 +343,6 @@ def fan_mode(self): return TADO_TO_HA_FAN_MODE_MAP.get(self._current_tado_fan_speed, FAN_AUTO) return None - @property - def fan_modes(self): - """List of available fan modes.""" - return self._supported_fan_modes - def set_fan_mode(self, fan_mode: str) -> None: """Turn fan on/off.""" self._control_hvac(fan_mode=HA_TO_TADO_FAN_MODE_MAP[fan_mode]) @@ -474,16 +463,6 @@ def swing_mode(self): """Active swing mode for the device.""" return TADO_TO_HA_SWING_MODE_MAP[self._current_tado_swing_mode] - @property - def swing_modes(self): - """Swing modes for the device.""" - if self.supported_features & ClimateEntityFeature.SWING_MODE: - return [ - TADO_TO_HA_SWING_MODE_MAP[TADO_SWING_ON], - TADO_TO_HA_SWING_MODE_MAP[TADO_SWING_OFF], - ] - return None - @property def extra_state_attributes(self): """Return temperature offset.""" diff --git a/homeassistant/components/tado/entity.py b/homeassistant/components/tado/entity.py index cfc9e5b1e6ef65..532d784b1908da 100644 --- a/homeassistant/components/tado/entity.py +++ b/homeassistant/components/tado/entity.py @@ -17,18 +17,14 @@ def __init__(self, device_info): self._device_info = device_info self.device_name = device_info["serialNo"] self.device_id = device_info["shortSerialNo"] - - @property - def device_info(self) -> DeviceInfo: - """Return the device_info of the device.""" - return DeviceInfo( + self._attr_device_info = DeviceInfo( configuration_url=f"https://app.tado.com/en/main/settings/rooms-and-devices/device/{self.device_name}", identifiers={(DOMAIN, self.device_id)}, name=self.device_name, manufacturer=DEFAULT_NAME, - sw_version=self._device_info["currentFwVersion"], - model=self._device_info["deviceType"], - via_device=(DOMAIN, self._device_info["serialNo"]), + sw_version=device_info["currentFwVersion"], + model=device_info["deviceType"], + via_device=(DOMAIN, device_info["serialNo"]), ) @@ -43,16 +39,12 @@ def __init__(self, tado): super().__init__() self.home_name = tado.home_name self.home_id = tado.home_id - - @property - def device_info(self) -> DeviceInfo: - """Return the device_info of the device.""" - return DeviceInfo( + self._attr_device_info = DeviceInfo( configuration_url="https://app.tado.com", - identifiers={(DOMAIN, self.home_id)}, + identifiers={(DOMAIN, tado.home_id)}, manufacturer=DEFAULT_NAME, model=TADO_HOME, - name=self.home_name, + name=tado.home_name, ) @@ -65,20 +57,13 @@ class TadoZoneEntity(Entity): def __init__(self, zone_name, home_id, zone_id): """Initialize a Tado zone.""" super().__init__() - self._device_zone_id = f"{home_id}_{zone_id}" self.zone_name = zone_name self.zone_id = zone_id - - @property - def device_info(self) -> DeviceInfo: - """Return the device_info of the device.""" - return DeviceInfo( - configuration_url=( - f"https://app.tado.com/en/main/home/zoneV2/{self.zone_id}" - ), - identifiers={(DOMAIN, self._device_zone_id)}, - name=self.zone_name, + self._attr_device_info = DeviceInfo( + configuration_url=(f"https://app.tado.com/en/main/home/zoneV2/{zone_id}"), + identifiers={(DOMAIN, f"{home_id}_{zone_id}")}, + name=zone_name, manufacturer=DEFAULT_NAME, model=TADO_ZONE, - suggested_area=self.zone_name, + suggested_area=zone_name, ) diff --git a/homeassistant/components/tado/sensor.py b/homeassistant/components/tado/sensor.py index f7ba1682e18578..c665cc3c592f82 100644 --- a/homeassistant/components/tado/sensor.py +++ b/homeassistant/components/tado/sensor.py @@ -52,6 +52,47 @@ class TadoSensorEntityDescription( data_category: str | None = None +def format_condition(condition: str) -> str: + """Return condition from dict CONDITIONS_MAP.""" + for key, value in CONDITIONS_MAP.items(): + if condition in value: + return key + return condition + + +def get_tado_mode(data) -> str | None: + """Return Tado Mode based on Presence attribute.""" + if "presence" in data: + return data["presence"] + return None + + +def get_automatic_geofencing(data) -> bool: + """Return whether Automatic Geofencing is enabled based on Presence Locked attribute.""" + if "presenceLocked" in data: + if data["presenceLocked"]: + return False + return True + return False + + +def get_geofencing_mode(data) -> str: + """Return Geofencing Mode based on Presence and Presence Locked attributes.""" + tado_mode = "" + tado_mode = data.get("presence", "unknown") + + geofencing_switch_mode = "" + if "presenceLocked" in data: + if data["presenceLocked"]: + geofencing_switch_mode = "manual" + else: + geofencing_switch_mode = "auto" + else: + geofencing_switch_mode = "manual" + + return f"{tado_mode.capitalize()} ({geofencing_switch_mode.capitalize()})" + + HOME_SENSORS = [ TadoSensorEntityDescription( key="outdoor temperature", @@ -86,22 +127,19 @@ class TadoSensorEntityDescription( TadoSensorEntityDescription( key="tado mode", translation_key="tado_mode", - # pylint: disable=unnecessary-lambda - state_fn=lambda data: get_tado_mode(data), + state_fn=get_tado_mode, data_category=SENSOR_DATA_CATEGORY_GEOFENCE, ), TadoSensorEntityDescription( key="geofencing mode", translation_key="geofencing_mode", - # pylint: disable=unnecessary-lambda - state_fn=lambda data: get_geofencing_mode(data), + state_fn=get_geofencing_mode, data_category=SENSOR_DATA_CATEGORY_GEOFENCE, ), TadoSensorEntityDescription( key="automatic geofencing", translation_key="automatic_geofencing", - # pylint: disable=unnecessary-lambda - state_fn=lambda data: get_automatic_geofencing(data), + state_fn=get_automatic_geofencing, data_category=SENSOR_DATA_CATEGORY_GEOFENCE, ), ] @@ -163,47 +201,6 @@ class TadoSensorEntityDescription( } -def format_condition(condition: str) -> str: - """Return condition from dict CONDITIONS_MAP.""" - for key, value in CONDITIONS_MAP.items(): - if condition in value: - return key - return condition - - -def get_tado_mode(data) -> str | None: - """Return Tado Mode based on Presence attribute.""" - if "presence" in data: - return data["presence"] - return None - - -def get_automatic_geofencing(data) -> bool: - """Return whether Automatic Geofencing is enabled based on Presence Locked attribute.""" - if "presenceLocked" in data: - if data["presenceLocked"]: - return False - return True - return False - - -def get_geofencing_mode(data) -> str: - """Return Geofencing Mode based on Presence and Presence Locked attributes.""" - tado_mode = "" - tado_mode = data.get("presence", "unknown") - - geofencing_switch_mode = "" - if "presenceLocked" in data: - if data["presenceLocked"]: - geofencing_switch_mode = "manual" - else: - geofencing_switch_mode = "auto" - else: - geofencing_switch_mode = "manual" - - return f"{tado_mode.capitalize()} ({geofencing_switch_mode.capitalize()})" - - async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: diff --git a/homeassistant/components/tado/water_heater.py b/homeassistant/components/tado/water_heater.py index 6d17c85c9811e5..b7e68bbb1003db 100644 --- a/homeassistant/components/tado/water_heater.py +++ b/homeassistant/components/tado/water_heater.py @@ -120,6 +120,8 @@ class TadoWaterHeater(TadoZoneEntity, WaterHeaterEntity): """Representation of a Tado water heater.""" _attr_name = None + _attr_operation_list = OPERATION_MODES + _attr_temperature_unit = UnitOfTemperature.CELSIUS def __init__( self, @@ -136,7 +138,7 @@ def __init__( super().__init__(zone_name, tado.home_id, zone_id) self.zone_id = zone_id - self._unique_id = f"{zone_id} {tado.home_id}" + self._attr_unique_id = f"{zone_id} {tado.home_id}" self._device_is_active = False @@ -168,11 +170,6 @@ async def async_added_to_hass(self) -> None: ) self._async_update_data() - @property - def unique_id(self): - """Return the unique id.""" - return self._unique_id - @property def current_operation(self): """Return current readable operation mode.""" @@ -188,16 +185,6 @@ def is_away_mode_on(self): """Return true if away mode is on.""" return self._tado_zone_data.is_away - @property - def operation_list(self): - """Return the list of available operation modes (readable).""" - return OPERATION_MODES - - @property - def temperature_unit(self): - """Return the unit of measurement used by the platform.""" - return UnitOfTemperature.CELSIUS - @property def min_temp(self): """Return the minimum temperature.""" diff --git a/homeassistant/components/tasmota/manifest.json b/homeassistant/components/tasmota/manifest.json index 220bc4e31fb7ca..42fc849a2cf10d 100644 --- a/homeassistant/components/tasmota/manifest.json +++ b/homeassistant/components/tasmota/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["hatasmota"], "mqtt": ["tasmota/discovery/#"], - "requirements": ["HATasmota==0.7.0"] + "requirements": ["HATasmota==0.7.3"] } diff --git a/homeassistant/components/tasmota/mixins.py b/homeassistant/components/tasmota/mixins.py index e99106d09e89a6..21030b8c14b07c 100644 --- a/homeassistant/components/tasmota/mixins.py +++ b/homeassistant/components/tasmota/mixins.py @@ -38,6 +38,9 @@ def __init__(self, tasmota_entity: HATasmotaEntity) -> None: """Initialize.""" self._tasmota_entity = tasmota_entity self._unique_id = tasmota_entity.unique_id + self._attr_device_info = DeviceInfo( + connections={(CONNECTION_NETWORK_MAC, tasmota_entity.mac)} + ) async def async_added_to_hass(self) -> None: """Subscribe to MQTT events.""" @@ -61,13 +64,6 @@ async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" await self._tasmota_entity.subscribe_topics() - @property - def device_info(self) -> DeviceInfo: - """Return a device description for device registry.""" - return DeviceInfo( - connections={(CONNECTION_NETWORK_MAC, self._tasmota_entity.mac)} - ) - @property def name(self) -> str | None: """Return the name of the binary sensor.""" diff --git a/homeassistant/components/tasmota/sensor.py b/homeassistant/components/tasmota/sensor.py index ddcdb3e8c26e4f..29d3f5c8c8a0a2 100644 --- a/homeassistant/components/tasmota/sensor.py +++ b/homeassistant/components/tasmota/sensor.py @@ -88,12 +88,10 @@ hc.SENSOR_COLOR_GREEN: {ICON: "mdi:palette"}, hc.SENSOR_COLOR_RED: {ICON: "mdi:palette"}, hc.SENSOR_CURRENT: { - ICON: "mdi:alpha-a-circle-outline", DEVICE_CLASS: SensorDeviceClass.CURRENT, STATE_CLASS: SensorStateClass.MEASUREMENT, }, hc.SENSOR_CURRENTNEUTRAL: { - ICON: "mdi:alpha-a-circle-outline", DEVICE_CLASS: SensorDeviceClass.CURRENT, STATE_CLASS: SensorStateClass.MEASUREMENT, }, @@ -103,11 +101,14 @@ STATE_CLASS: SensorStateClass.MEASUREMENT, }, hc.SENSOR_DISTANCE: { - ICON: "mdi:leak", DEVICE_CLASS: SensorDeviceClass.DISTANCE, STATE_CLASS: SensorStateClass.MEASUREMENT, }, hc.SENSOR_ECO2: {ICON: "mdi:molecule-co2"}, + hc.SENSOR_ENERGY: { + DEVICE_CLASS: SensorDeviceClass.ENERGY, + STATE_CLASS: SensorStateClass.TOTAL, + }, hc.SENSOR_FREQUENCY: { DEVICE_CLASS: SensorDeviceClass.FREQUENCY, STATE_CLASS: SensorStateClass.MEASUREMENT, @@ -122,10 +123,7 @@ }, hc.SENSOR_STATUS_IP: {ICON: "mdi:ip-network"}, hc.SENSOR_STATUS_LINK_COUNT: {ICON: "mdi:counter"}, - hc.SENSOR_MOISTURE: { - DEVICE_CLASS: SensorDeviceClass.MOISTURE, - ICON: "mdi:cup-water", - }, + hc.SENSOR_MOISTURE: {DEVICE_CLASS: SensorDeviceClass.MOISTURE}, hc.SENSOR_STATUS_MQTT_COUNT: {ICON: "mdi:counter"}, hc.SENSOR_PB0_3: {ICON: "mdi:flask"}, hc.SENSOR_PB0_5: {ICON: "mdi:flask"}, @@ -146,7 +144,6 @@ STATE_CLASS: SensorStateClass.MEASUREMENT, }, hc.SENSOR_POWERFACTOR: { - ICON: "mdi:alpha-f-circle-outline", DEVICE_CLASS: SensorDeviceClass.POWER_FACTOR, STATE_CLASS: SensorStateClass.MEASUREMENT, }, @@ -162,7 +159,7 @@ DEVICE_CLASS: SensorDeviceClass.PRESSURE, STATE_CLASS: SensorStateClass.MEASUREMENT, }, - hc.SENSOR_PROXIMITY: {DEVICE_CLASS: SensorDeviceClass.DISTANCE, ICON: "mdi:ruler"}, + hc.SENSOR_PROXIMITY: {ICON: "mdi:ruler"}, hc.SENSOR_REACTIVE_ENERGYEXPORT: {STATE_CLASS: SensorStateClass.TOTAL}, hc.SENSOR_REACTIVE_ENERGYIMPORT: {STATE_CLASS: SensorStateClass.TOTAL}, hc.SENSOR_REACTIVE_POWERUSAGE: { @@ -195,11 +192,10 @@ hc.SENSOR_TOTAL_START_TIME: {ICON: "mdi:progress-clock"}, hc.SENSOR_TVOC: {ICON: "mdi:air-filter"}, hc.SENSOR_VOLTAGE: { - ICON: "mdi:alpha-v-circle-outline", + DEVICE_CLASS: SensorDeviceClass.VOLTAGE, STATE_CLASS: SensorStateClass.MEASUREMENT, }, hc.SENSOR_WEIGHT: { - ICON: "mdi:scale", DEVICE_CLASS: SensorDeviceClass.WEIGHT, STATE_CLASS: SensorStateClass.MEASUREMENT, }, @@ -220,7 +216,6 @@ hc.LIGHT_LUX: LIGHT_LUX, hc.MASS_KILOGRAMS: UnitOfMass.KILOGRAMS, hc.PERCENTAGE: PERCENTAGE, - hc.POWER_FACTOR: None, hc.POWER_WATT: UnitOfPower.WATT, hc.PRESSURE_HPA: UnitOfPressure.HPA, hc.REACTIVE_POWER: POWER_VOLT_AMPERE_REACTIVE, @@ -279,6 +274,26 @@ def __init__(self, **kwds: Any) -> None: **kwds, ) + class_or_icon = SENSOR_DEVICE_CLASS_ICON_MAP.get( + self._tasmota_entity.quantity, {} + ) + self._attr_device_class = class_or_icon.get(DEVICE_CLASS) + self._attr_state_class = class_or_icon.get(STATE_CLASS) + if self._tasmota_entity.quantity in status_sensor.SENSORS: + self._attr_entity_category = EntityCategory.DIAGNOSTIC + # Hide fast changing status sensors + if self._tasmota_entity.quantity in ( + hc.SENSOR_STATUS_IP, + hc.SENSOR_STATUS_RSSI, + hc.SENSOR_STATUS_SIGNAL, + hc.SENSOR_STATUS_VERSION, + ): + self._attr_entity_registry_enabled_default = False + self._attr_icon = class_or_icon.get(ICON) + self._attr_native_unit_of_measurement = SENSOR_UNIT_MAP.get( + self._tasmota_entity.unit, self._tasmota_entity.unit + ) + async def async_added_to_hass(self) -> None: """Subscribe to MQTT events.""" self._tasmota_entity.set_on_state_callback(self.sensor_state_updated) @@ -293,58 +308,9 @@ def sensor_state_updated(self, state: Any, **kwargs: Any) -> None: self._state = state self.async_write_ha_state() - @property - def device_class(self) -> SensorDeviceClass | None: - """Return the device class of the sensor.""" - class_or_icon = SENSOR_DEVICE_CLASS_ICON_MAP.get( - self._tasmota_entity.quantity, {} - ) - return class_or_icon.get(DEVICE_CLASS) - - @property - def state_class(self) -> str | None: - """Return the state class of the sensor.""" - class_or_icon = SENSOR_DEVICE_CLASS_ICON_MAP.get( - self._tasmota_entity.quantity, {} - ) - return class_or_icon.get(STATE_CLASS) - - @property - def entity_category(self) -> EntityCategory | None: - """Return the category of the entity, if any.""" - if self._tasmota_entity.quantity in status_sensor.SENSORS: - return EntityCategory.DIAGNOSTIC - return None - - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - # Hide fast changing status sensors - if self._tasmota_entity.quantity in ( - hc.SENSOR_STATUS_IP, - hc.SENSOR_STATUS_RSSI, - hc.SENSOR_STATUS_SIGNAL, - hc.SENSOR_STATUS_VERSION, - ): - return False - return True - - @property - def icon(self) -> str | None: - """Return the icon.""" - class_or_icon = SENSOR_DEVICE_CLASS_ICON_MAP.get( - self._tasmota_entity.quantity, {} - ) - return class_or_icon.get(ICON) - @property def native_value(self) -> datetime | str | None: """Return the state of the entity.""" if self._state_timestamp and self.device_class == SensorDeviceClass.TIMESTAMP: return self._state_timestamp return self._state - - @property - def native_unit_of_measurement(self) -> str | None: - """Return the unit this state is expressed in.""" - return SENSOR_UNIT_MAP.get(self._tasmota_entity.unit, self._tasmota_entity.unit) diff --git a/homeassistant/components/tellduslive/sensor.py b/homeassistant/components/tellduslive/sensor.py index e15f89888b1717..06b505d95742d1 100644 --- a/homeassistant/components/tellduslive/sensor.py +++ b/homeassistant/components/tellduslive/sensor.py @@ -142,6 +142,7 @@ class TelldusLiveSensor(TelldusLiveEntity, SensorEntity): def __init__(self, client, device_id): """Initialize TelldusLiveSensor.""" super().__init__(client, device_id) + self._attr_unique_id = "{}-{}-{}".format(*device_id) if desc := SENSOR_TYPES.get(self._type): self.entity_description = desc else: @@ -189,8 +190,3 @@ def native_value(self): if self._type == SENSOR_TYPE_LUMINANCE: return self._value_as_luminance return self._value - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return "{}-{}-{}".format(*self._id) diff --git a/homeassistant/components/template/__init__.py b/homeassistant/components/template/__init__.py index e9ced060491900..22919ac9e708e1 100644 --- a/homeassistant/components/template/__init__.py +++ b/homeassistant/components/template/__init__.py @@ -2,29 +2,21 @@ from __future__ import annotations import asyncio -from collections.abc import Callable import logging from homeassistant import config as conf_util from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_UNIQUE_ID, - EVENT_HOMEASSISTANT_START, - SERVICE_RELOAD, -) -from homeassistant.core import CoreState, Event, HomeAssistant, ServiceCall, callback +from homeassistant.const import CONF_UNIQUE_ID, SERVICE_RELOAD +from homeassistant.core import Event, HomeAssistant, ServiceCall from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import ( - discovery, - trigger as trigger_helper, - update_coordinator, -) +from homeassistant.helpers import discovery from homeassistant.helpers.reload import async_reload_integration_platforms from homeassistant.helpers.service import async_register_admin_service from homeassistant.helpers.typing import ConfigType from homeassistant.loader import async_get_integration from .const import CONF_TRIGGER, DOMAIN, PLATFORMS +from .coordinator import TriggerUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -120,71 +112,3 @@ async def init_coordinator(hass, conf_section): if coordinator_tasks: hass.data[DOMAIN] = await asyncio.gather(*coordinator_tasks) - - -class TriggerUpdateCoordinator(update_coordinator.DataUpdateCoordinator): - """Class to handle incoming data.""" - - REMOVE_TRIGGER = object() - - def __init__(self, hass, config): - """Instantiate trigger data.""" - super().__init__(hass, _LOGGER, name="Trigger Update Coordinator") - self.config = config - self._unsub_start: Callable[[], None] | None = None - self._unsub_trigger: Callable[[], None] | None = None - - @property - def unique_id(self) -> str | None: - """Return unique ID for the entity.""" - return self.config.get("unique_id") - - @callback - def async_remove(self): - """Signal that the entities need to remove themselves.""" - if self._unsub_start: - self._unsub_start() - if self._unsub_trigger: - self._unsub_trigger() - - async def async_setup(self, hass_config: ConfigType) -> None: - """Set up the trigger and create entities.""" - if self.hass.state == CoreState.running: - await self._attach_triggers() - else: - self._unsub_start = self.hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_START, self._attach_triggers - ) - - for platform_domain in PLATFORMS: - if platform_domain in self.config: - self.hass.async_create_task( - discovery.async_load_platform( - self.hass, - platform_domain, - DOMAIN, - {"coordinator": self, "entities": self.config[platform_domain]}, - hass_config, - ) - ) - - async def _attach_triggers(self, start_event=None) -> None: - """Attach the triggers.""" - if start_event is not None: - self._unsub_start = None - - self._unsub_trigger = await trigger_helper.async_initialize_triggers( - self.hass, - self.config[CONF_TRIGGER], - self._handle_triggered, - DOMAIN, - self.name, - self.logger.log, - start_event is not None, - ) - - @callback - def _handle_triggered(self, run_variables, context=None): - self.async_set_updated_data( - {"run_variables": run_variables, "context": context} - ) diff --git a/homeassistant/components/template/alarm_control_panel.py b/homeassistant/components/template/alarm_control_panel.py index af2e432c61e4cb..2cac5d74a7a30c 100644 --- a/homeassistant/components/template/alarm_control_panel.py +++ b/homeassistant/components/template/alarm_control_panel.py @@ -154,8 +154,8 @@ def __init__( name = self._attr_name self._template = config.get(CONF_VALUE_TEMPLATE) self._disarm_script = None - self._code_arm_required: bool = config[CONF_CODE_ARM_REQUIRED] - self._code_format: TemplateCodeFormat = config[CONF_CODE_FORMAT] + self._attr_code_arm_required: bool = config[CONF_CODE_ARM_REQUIRED] + self._attr_code_format = config[CONF_CODE_FORMAT].value if (disarm_action := config.get(CONF_DISARM_ACTION)) is not None: self._disarm_script = Script(hass, disarm_action, name, DOMAIN) self._arm_away_script = None @@ -183,14 +183,6 @@ def __init__( self._state: str | None = None - @property - def state(self) -> str | None: - """Return the state of the device.""" - return self._state - - @property - def supported_features(self) -> AlarmControlPanelEntityFeature: - """Return the list of supported features.""" supported_features = AlarmControlPanelEntityFeature(0) if self._arm_night_script is not None: supported_features = ( @@ -221,18 +213,12 @@ def supported_features(self) -> AlarmControlPanelEntityFeature: supported_features = ( supported_features | AlarmControlPanelEntityFeature.TRIGGER ) - - return supported_features - - @property - def code_format(self) -> CodeFormat | None: - """Regex for code format or None if no code is required.""" - return self._code_format.value + self._attr_supported_features = supported_features @property - def code_arm_required(self) -> bool: - """Whether the code is required for arm actions.""" - return self._code_arm_required + def state(self) -> str | None: + """Return the state of the device.""" + return self._state @callback def _update_state(self, result): diff --git a/homeassistant/components/template/binary_sensor.py b/homeassistant/components/template/binary_sensor.py index ca0ed583d86e94..427fe6221cd242 100644 --- a/homeassistant/components/template/binary_sensor.py +++ b/homeassistant/components/template/binary_sensor.py @@ -14,7 +14,6 @@ DOMAIN as BINARY_SENSOR_DOMAIN, ENTITY_ID_FORMAT, PLATFORM_SCHEMA, - BinarySensorDeviceClass, BinarySensorEntity, ) from homeassistant.config_entries import ConfigEntry @@ -236,9 +235,7 @@ def __init__( ENTITY_ID_FORMAT, object_id, hass=hass ) - self._device_class: BinarySensorDeviceClass | None = config.get( - CONF_DEVICE_CLASS - ) + self._attr_device_class = config.get(CONF_DEVICE_CLASS) self._template = config[CONF_STATE] self._state: bool | None = None self._delay_cancel = None @@ -321,11 +318,6 @@ def is_on(self) -> bool | None: """Return true if sensor is on.""" return self._state - @property - def device_class(self) -> BinarySensorDeviceClass | None: - """Return the sensor class of the binary sensor.""" - return self._device_class - class TriggerBinarySensorEntity(TriggerEntity, BinarySensorEntity, RestoreEntity): """Sensor entity based on trigger data.""" diff --git a/homeassistant/components/template/config.py b/homeassistant/components/template/config.py index 2261bde265950c..54c82d88c74bce 100644 --- a/homeassistant/components/template/config.py +++ b/homeassistant/components/template/config.py @@ -22,7 +22,7 @@ select as select_platform, sensor as sensor_platform, ) -from .const import CONF_TRIGGER, DOMAIN +from .const import CONF_ACTION, CONF_TRIGGER, DOMAIN PACKAGE_MERGE_HINT = "list" @@ -30,6 +30,7 @@ { vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_TRIGGER): cv.TRIGGER_SCHEMA, + vol.Optional(CONF_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(NUMBER_DOMAIN): vol.All( cv.ensure_list, [number_platform.NUMBER_SCHEMA] ), diff --git a/homeassistant/components/template/config_flow.py b/homeassistant/components/template/config_flow.py index b2ccddedad8ba0..c361b4c42cc817 100644 --- a/homeassistant/components/template/config_flow.py +++ b/homeassistant/components/template/config_flow.py @@ -24,7 +24,7 @@ ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import selector +from homeassistant.helpers import entity_registry as er, selector from homeassistant.helpers.schema_config_entry_flow import ( SchemaCommonFlowHandler, SchemaConfigFlowHandler, @@ -40,11 +40,11 @@ NONE_SENTINEL = "none" -def generate_schema(domain: str) -> dict[vol.Marker, Any]: +def generate_schema(domain: str, flow_type: str) -> dict[vol.Marker, Any]: """Generate schema.""" schema: dict[vol.Marker, Any] = {} - if domain == Platform.BINARY_SENSOR: + if domain == Platform.BINARY_SENSOR and flow_type == "config": schema = { vol.Optional(CONF_DEVICE_CLASS): selector.SelectSelector( selector.SelectSelectorConfig( @@ -124,7 +124,7 @@ def options_schema(domain: str) -> vol.Schema: """Generate options schema.""" return vol.Schema( {vol.Required(CONF_STATE): selector.TemplateSelector()} - | generate_schema(domain), + | generate_schema(domain, "option"), ) @@ -135,7 +135,7 @@ def config_schema(domain: str) -> vol.Schema: vol.Required(CONF_NAME): selector.TextSelector(), vol.Required(CONF_STATE): selector.TemplateSelector(), } - | generate_schema(domain), + | generate_schema(domain, "config"), ) @@ -208,6 +208,7 @@ def validate_user_input( ]: """Do post validation of user input. + For binary sensors: Strip none-sentinels. For sensors: Strip none-sentinels and validate unit of measurement. For all domaines: Set template type. """ @@ -217,8 +218,9 @@ async def _validate_user_input( user_input: dict[str, Any], ) -> dict[str, Any]: """Add template type to user input.""" - if template_type == Platform.SENSOR: + if template_type in (Platform.BINARY_SENSOR, Platform.SENSOR): _strip_sentinel(user_input) + if template_type == Platform.SENSOR: _validate_unit(user_input) _validate_state_class(user_input) return {"template_type": template_type} | user_input @@ -326,6 +328,7 @@ def _validate(schema: vol.Schema, domain: str, user_input: dict[str, Any]) -> An return errors + entity_registry_entry: er.RegistryEntry | None = None if msg["flow_type"] == "config_flow": flow_status = hass.config_entries.flow.async_get(msg["flow_id"]) template_type = flow_status["step_id"] @@ -340,6 +343,12 @@ def _validate(schema: vol.Schema, domain: str, user_input: dict[str, Any]) -> An template_type = config_entry.options["template_type"] name = config_entry.options["name"] schema = cast(vol.Schema, OPTIONS_FLOW[template_type].schema) + entity_registry = er.async_get(hass) + entries = er.async_entries_for_config_entry( + entity_registry, flow_status["handler"] + ) + if entries: + entity_registry_entry = entries[0] errors = _validate(schema, template_type, msg["user_input"]) @@ -347,6 +356,7 @@ def _validate(schema: vol.Schema, domain: str, user_input: dict[str, Any]) -> An def async_preview_updated( state: str | None, attributes: Mapping[str, Any] | None, + listeners: dict[str, bool | set[str]] | None, error: str | None, ) -> None: """Forward config entry state events to websocket.""" @@ -361,7 +371,7 @@ def async_preview_updated( connection.send_message( websocket_api.event_message( msg["id"], - {"attributes": attributes, "state": state}, + {"attributes": attributes, "listeners": listeners, "state": state}, ) ) @@ -379,6 +389,7 @@ def async_preview_updated( _strip_sentinel(msg["user_input"]) preview_entity = CREATE_PREVIEW_ENTITY[template_type](hass, name, msg["user_input"]) preview_entity.hass = hass + preview_entity.registry_entry = entity_registry_entry connection.send_result(msg["id"]) connection.subscriptions[msg["id"]] = preview_entity.async_start_preview( diff --git a/homeassistant/components/template/const.py b/homeassistant/components/template/const.py index 9b371125750f76..6805c0ad81282e 100644 --- a/homeassistant/components/template/const.py +++ b/homeassistant/components/template/const.py @@ -2,6 +2,7 @@ from homeassistant.const import Platform +CONF_ACTION = "action" CONF_AVAILABILITY_TEMPLATE = "availability_template" CONF_ATTRIBUTE_TEMPLATES = "attribute_templates" CONF_TRIGGER = "trigger" diff --git a/homeassistant/components/template/coordinator.py b/homeassistant/components/template/coordinator.py new file mode 100644 index 00000000000000..7f24fe731cc024 --- /dev/null +++ b/homeassistant/components/template/coordinator.py @@ -0,0 +1,94 @@ +"""Data update coordinator for trigger based template entities.""" +from collections.abc import Callable +import logging + +from homeassistant.const import EVENT_HOMEASSISTANT_START +from homeassistant.core import CoreState, callback +from homeassistant.helpers import discovery, trigger as trigger_helper +from homeassistant.helpers.script import Script +from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import CONF_ACTION, CONF_TRIGGER, DOMAIN, PLATFORMS + +_LOGGER = logging.getLogger(__name__) + + +class TriggerUpdateCoordinator(DataUpdateCoordinator): + """Data update coordinator for trigger based template entities.""" + + REMOVE_TRIGGER = object() + + def __init__(self, hass, config): + """Instantiate trigger data.""" + super().__init__(hass, _LOGGER, name="Trigger Update Coordinator") + self.config = config + self._unsub_start: Callable[[], None] | None = None + self._unsub_trigger: Callable[[], None] | None = None + self._script: Script | None = None + + @property + def unique_id(self) -> str | None: + """Return unique ID for the entity.""" + return self.config.get("unique_id") + + @callback + def async_remove(self): + """Signal that the entities need to remove themselves.""" + if self._unsub_start: + self._unsub_start() + if self._unsub_trigger: + self._unsub_trigger() + + async def async_setup(self, hass_config: ConfigType) -> None: + """Set up the trigger and create entities.""" + if self.hass.state == CoreState.running: + await self._attach_triggers() + else: + self._unsub_start = self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_START, self._attach_triggers + ) + + for platform_domain in PLATFORMS: + if platform_domain in self.config: + self.hass.async_create_task( + discovery.async_load_platform( + self.hass, + platform_domain, + DOMAIN, + {"coordinator": self, "entities": self.config[platform_domain]}, + hass_config, + ) + ) + + async def _attach_triggers(self, start_event=None) -> None: + """Attach the triggers.""" + if CONF_ACTION in self.config: + self._script = Script( + self.hass, + self.config[CONF_ACTION], + self.name, + DOMAIN, + ) + + if start_event is not None: + self._unsub_start = None + + self._unsub_trigger = await trigger_helper.async_initialize_triggers( + self.hass, + self.config[CONF_TRIGGER], + self._handle_triggered, + DOMAIN, + self.name, + self.logger.log, + start_event is not None, + ) + + async def _handle_triggered(self, run_variables, context=None): + if self._script: + script_result = await self._script.async_run(run_variables, context) + if script_result: + run_variables = script_result.variables + self.async_set_updated_data( + {"run_variables": run_variables, "context": context} + ) diff --git a/homeassistant/components/template/cover.py b/homeassistant/components/template/cover.py index 3a8e536f7f50f3..5daa4531109fd7 100644 --- a/homeassistant/components/template/cover.py +++ b/homeassistant/components/template/cover.py @@ -12,7 +12,6 @@ DEVICE_CLASSES_SCHEMA, ENTITY_ID_FORMAT, PLATFORM_SCHEMA, - CoverDeviceClass, CoverEntity, CoverEntityFeature, ) @@ -155,7 +154,7 @@ def __init__( self._template = config.get(CONF_VALUE_TEMPLATE) self._position_template = config.get(CONF_POSITION_TEMPLATE) self._tilt_template = config.get(CONF_TILT_TEMPLATE) - self._device_class: CoverDeviceClass | None = config.get(CONF_DEVICE_CLASS) + self._attr_device_class = config.get(CONF_DEVICE_CLASS) self._open_script = None if (open_action := config.get(OPEN_ACTION)) is not None: self._open_script = Script(hass, open_action, friendly_name, DOMAIN) @@ -182,6 +181,15 @@ def __init__( self._is_closing = False self._tilt_value = None + supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE + if self._stop_script is not None: + supported_features |= CoverEntityFeature.STOP + if self._position_script is not None: + supported_features |= CoverEntityFeature.SET_POSITION + if self._tilt_script is not None: + supported_features |= TILT_FEATURES + self._attr_supported_features = supported_features + @callback def _async_setup_templates(self) -> None: """Set up templates.""" @@ -318,27 +326,6 @@ def current_cover_tilt_position(self) -> int | None: """ return self._tilt_value - @property - def device_class(self) -> CoverDeviceClass | None: - """Return the device class of the cover.""" - return self._device_class - - @property - def supported_features(self) -> CoverEntityFeature: - """Flag supported features.""" - supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE - - if self._stop_script is not None: - supported_features |= CoverEntityFeature.STOP - - if self._position_script is not None: - supported_features |= CoverEntityFeature.SET_POSITION - - if self._tilt_script is not None: - supported_features |= TILT_FEATURES - - return supported_features - async def async_open_cover(self, **kwargs: Any) -> None: """Move the cover up.""" if self._open_script: diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py index c07c680887b21d..d39fa56775a625 100644 --- a/homeassistant/components/template/fan.py +++ b/homeassistant/components/template/fan.py @@ -195,6 +195,8 @@ def __init__( if self._direction_template: self._attr_supported_features |= FanEntityFeature.DIRECTION + self._attr_assumed_state = self._template is None + @property def speed_count(self) -> int: """Return the number of speeds the fan supports.""" @@ -467,8 +469,3 @@ def _update_direction(self, direction): ", ".join(_VALID_DIRECTIONS), ) self._direction = None - - @property - def assumed_state(self) -> bool: - """State is assumed, if no template given.""" - return self._template is None diff --git a/homeassistant/components/template/light.py b/homeassistant/components/template/light.py index 09f5054ed51cef..b3f276240b5910 100644 --- a/homeassistant/components/template/light.py +++ b/homeassistant/components/template/light.py @@ -197,6 +197,12 @@ def __init__( if len(self._supported_color_modes) == 1: self._fixed_color_mode = next(iter(self._supported_color_modes)) + self._attr_supported_features = LightEntityFeature(0) + if self._effect_script is not None: + self._attr_supported_features |= LightEntityFeature.EFFECT + if self._supports_transition is True: + self._attr_supported_features |= LightEntityFeature.TRANSITION + @property def brightness(self) -> int | None: """Return the brightness of the light.""" @@ -253,16 +259,6 @@ def supported_color_modes(self): """Flag supported color modes.""" return self._supported_color_modes - @property - def supported_features(self) -> LightEntityFeature: - """Flag supported features.""" - supported_features = LightEntityFeature(0) - if self._effect_script is not None: - supported_features |= LightEntityFeature.EFFECT - if self._supports_transition is True: - supported_features |= LightEntityFeature.TRANSITION - return supported_features - @property def is_on(self) -> bool | None: """Return true if device is on.""" @@ -644,4 +640,7 @@ def _update_supports_transition(self, render): if render in (None, "None", ""): self._supports_transition = False return + self._attr_supported_features &= LightEntityFeature.EFFECT self._supports_transition = bool(render) + if self._supports_transition: + self._attr_supported_features |= LightEntityFeature.TRANSITION diff --git a/homeassistant/components/template/lock.py b/homeassistant/components/template/lock.py index d8c7127f0e60ce..de483971ac68a1 100644 --- a/homeassistant/components/template/lock.py +++ b/homeassistant/components/template/lock.py @@ -90,11 +90,7 @@ def __init__( self._command_lock = Script(hass, config[CONF_LOCK], name, DOMAIN) self._command_unlock = Script(hass, config[CONF_UNLOCK], name, DOMAIN) self._optimistic = config.get(CONF_OPTIMISTIC) - - @property - def assumed_state(self) -> bool: - """Return true if we do optimistic updates.""" - return bool(self._optimistic) + self._attr_assumed_state = bool(self._optimistic) @property def is_locked(self) -> bool: diff --git a/homeassistant/components/template/sensor.py b/homeassistant/components/template/sensor.py index cdd14921bc1c34..e757f561a7e7e7 100644 --- a/homeassistant/components/template/sensor.py +++ b/homeassistant/components/template/sensor.py @@ -42,6 +42,7 @@ from homeassistant.helpers.trigger_template_entity import TEMPLATE_SENSOR_BASE_SCHEMA from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from . import TriggerUpdateCoordinator from .const import ( CONF_ATTRIBUTE_TEMPLATES, CONF_AVAILABILITY_TEMPLATE, @@ -274,6 +275,17 @@ class TriggerSensorEntity(TriggerEntity, RestoreSensor): domain = SENSOR_DOMAIN extra_template_keys = (CONF_STATE,) + def __init__( + self, + hass: HomeAssistant, + coordinator: TriggerUpdateCoordinator, + config: ConfigType, + ) -> None: + """Initialize.""" + super().__init__(hass, coordinator, config) + self._attr_state_class = config.get(CONF_STATE_CLASS) + self._attr_native_unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT) + async def async_added_to_hass(self) -> None: """Restore last state.""" await super().async_added_to_hass() @@ -293,16 +305,6 @@ def native_value(self) -> str | datetime | date | None: """Return state of the sensor.""" return self._rendered.get(CONF_STATE) - @property - def state_class(self) -> str | None: - """Sensor state class.""" - return self._config.get(CONF_STATE_CLASS) - - @property - def native_unit_of_measurement(self) -> str | None: - """Return the unit of measurement of the sensor, if any.""" - return self._config.get(CONF_UNIT_OF_MEASUREMENT) - @callback def _process_data(self) -> None: """Process new data.""" diff --git a/homeassistant/components/template/strings.json b/homeassistant/components/template/strings.json index 7e5e56a26d6248..a0ee31126cd75a 100644 --- a/homeassistant/components/template/strings.json +++ b/homeassistant/components/template/strings.json @@ -33,7 +33,6 @@ "step": { "binary_sensor": { "data": { - "device_class": "[%key:component::template::config::step::sensor::data::device_class%]", "state": "[%key:component::template::config::step::sensor::data::state%]" }, "title": "[%key:component::template::config::step::binary_sensor::title%]" diff --git a/homeassistant/components/template/switch.py b/homeassistant/components/template/switch.py index 39270d3fc6d509..5e75eafe233196 100644 --- a/homeassistant/components/template/switch.py +++ b/homeassistant/components/template/switch.py @@ -113,6 +113,7 @@ def __init__( self._on_script = Script(hass, config[ON_ACTION], friendly_name, DOMAIN) self._off_script = Script(hass, config[OFF_ACTION], friendly_name, DOMAIN) self._state: bool | None = False + self._attr_assumed_state = self._template is None @callback def _update_state(self, result): @@ -168,8 +169,3 @@ async def async_turn_off(self, **kwargs: Any) -> None: if self._template is None: self._state = False self.async_write_ha_state() - - @property - def assumed_state(self) -> bool: - """State is assumed, if no template given.""" - return self._template is None diff --git a/homeassistant/components/template/template_entity.py b/homeassistant/components/template/template_entity.py index ac06e2c8734f9d..8c3554c067e601 100644 --- a/homeassistant/components/template/template_entity.py +++ b/homeassistant/components/template/template_entity.py @@ -15,16 +15,15 @@ CONF_ICON, CONF_ICON_TEMPLATE, CONF_NAME, - EVENT_HOMEASSISTANT_START, STATE_UNKNOWN, ) from homeassistant.core import ( CALLBACK_TYPE, Context, - CoreState, HomeAssistant, State, callback, + validate_state, ) from homeassistant.exceptions import TemplateError import homeassistant.helpers.config_validation as cv @@ -33,9 +32,11 @@ EventStateChangedData, TrackTemplate, TrackTemplateResult, + TrackTemplateResultInfo, async_track_template_result, ) from homeassistant.helpers.script import Script, _VarsType +from homeassistant.helpers.start import async_at_start from homeassistant.helpers.template import ( Template, TemplateStateFromEntityId, @@ -259,12 +260,18 @@ def __init__( ) -> None: """Template Entity.""" self._template_attrs: dict[Template, list[_TemplateAttribute]] = {} - self._async_update: Callable[[], None] | None = None + self._template_result_info: TrackTemplateResultInfo | None = None self._attr_extra_state_attributes = {} self._self_ref_update_count = 0 self._attr_unique_id = unique_id self._preview_callback: Callable[ - [str | None, dict[str, Any] | None, str | None], None + [ + str | None, + dict[str, Any] | None, + dict[str, bool | set[str]] | None, + str | None, + ], + None, ] | None = None if config is None: self._attribute_templates = attribute_templates @@ -413,8 +420,8 @@ def _handle_results( return for update in updates: - for attr in self._template_attrs[update.template]: - attr.handle_result( + for template_attr in self._template_attrs[update.template]: + template_attr.handle_result( event, update.template, update.last_result, update.result ) @@ -422,10 +429,23 @@ def _handle_results( self.async_write_ha_state() return - self._preview_callback(*self._async_generate_attributes(), None) + try: + state, attrs = self._async_generate_attributes() + validate_state(state) + except Exception as err: # pylint: disable=broad-exception-caught + self._preview_callback(None, None, None, str(err)) + else: + assert self._template_result_info + self._preview_callback( + state, attrs, self._template_result_info.listeners, None + ) @callback - def _async_template_startup(self, *_: Any) -> None: + def _async_template_startup( + self, + _hass: HomeAssistant | None, + log_fn: Callable[[int, str], None] | None = None, + ) -> None: template_var_tups: list[TrackTemplate] = [] has_availability_template = False @@ -450,10 +470,11 @@ def _async_template_startup(self, *_: Any) -> None: self.hass, template_var_tups, self._handle_results, + log_fn=log_fn, has_super_template=has_availability_template, ) self.async_on_remove(result_info.async_remove) - self._async_update = result_info.async_refresh + self._template_result_info = result_info result_info.async_refresh() @callback @@ -487,35 +508,38 @@ def _async_setup_templates(self) -> None: def async_start_preview( self, preview_callback: Callable[ - [str | None, Mapping[str, Any] | None, str | None], None + [ + str | None, + Mapping[str, Any] | None, + dict[str, bool | set[str]] | None, + str | None, + ], + None, ], ) -> CALLBACK_TYPE: """Render a preview.""" + def log_template_error(level: int, msg: str) -> None: + preview_callback(None, None, None, msg) + self._preview_callback = preview_callback self._async_setup_templates() try: - self._async_template_startup() + self._async_template_startup(None, log_template_error) except Exception as err: # pylint: disable=broad-exception-caught - preview_callback(None, None, str(err)) + preview_callback(None, None, None, str(err)) return self._call_on_remove_callbacks async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" self._async_setup_templates() - if self.hass.state == CoreState.running: - self._async_template_startup() - return - - self.hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_START, self._async_template_startup - ) + async_at_start(self.hass, self._async_template_startup) async def async_update(self) -> None: """Call for forced update.""" - assert self._async_update - self._async_update() + assert self._template_result_info + self._template_result_info.async_refresh() async def async_run_script( self, diff --git a/homeassistant/components/template/trigger_entity.py b/homeassistant/components/template/trigger_entity.py index ca2f7240086d78..5f5fbe5b99a16e 100644 --- a/homeassistant/components/template/trigger_entity.py +++ b/homeassistant/components/template/trigger_entity.py @@ -23,8 +23,7 @@ def __init__( async def async_added_to_hass(self) -> None: """Handle being added to Home Assistant.""" - await TriggerBaseEntity.async_added_to_hass(self) - await CoordinatorEntity.async_added_to_hass(self) # type: ignore[arg-type] + await super().async_added_to_hass() if self.coordinator.data is not None: self._process_data() diff --git a/homeassistant/components/tensorflow/manifest.json b/homeassistant/components/tensorflow/manifest.json index 71952431b5ae3b..bfd3e77ee50cf9 100644 --- a/homeassistant/components/tensorflow/manifest.json +++ b/homeassistant/components/tensorflow/manifest.json @@ -9,7 +9,7 @@ "tensorflow==2.5.0", "tf-models-official==2.5.0", "pycocotools==2.0.6", - "numpy==1.23.2", + "numpy==1.26.0", "Pillow==10.0.0" ] } diff --git a/homeassistant/components/text/__init__.py b/homeassistant/components/text/__init__.py index 4182b177bf6db0..acc5f62a0cc57d 100644 --- a/homeassistant/components/text/__init__.py +++ b/homeassistant/components/text/__init__.py @@ -111,6 +111,10 @@ class TextEntityDescription(EntityDescription): class TextEntity(Entity): """Representation of a Text entity.""" + _entity_component_unrecorded_attributes = frozenset( + {ATTR_MAX, ATTR_MIN, ATTR_MODE, ATTR_PATTERN} + ) + entity_description: TextEntityDescription _attr_mode: TextMode _attr_native_value: str | None diff --git a/homeassistant/components/text/recorder.py b/homeassistant/components/text/recorder.py deleted file mode 100644 index 09642eb3079d01..00000000000000 --- a/homeassistant/components/text/recorder.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.core import HomeAssistant, callback - -from . import ATTR_MAX, ATTR_MIN, ATTR_MODE, ATTR_PATTERN - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude static attributes from being recorded in the database.""" - return {ATTR_MAX, ATTR_MIN, ATTR_MODE, ATTR_PATTERN} diff --git a/homeassistant/components/text/strings.json b/homeassistant/components/text/strings.json index e6b3d99ced4a5d..82cab559d0e568 100644 --- a/homeassistant/components/text/strings.json +++ b/homeassistant/components/text/strings.json @@ -16,10 +16,10 @@ "name": "Min length" }, "mode": { - "name": "Mode", + "name": "[%key:common::config_flow::data::mode%]", "state": { "text": "Text", - "password": "Password" + "password": "[%key:common::config_flow::data::password%]" } }, "pattern": { diff --git a/homeassistant/components/threshold/binary_sensor.py b/homeassistant/components/threshold/binary_sensor.py index 3e702f0ebdbb30..6382c79b9ce877 100644 --- a/homeassistant/components/threshold/binary_sensor.py +++ b/homeassistant/components/threshold/binary_sensor.py @@ -183,14 +183,14 @@ def __init__( self._attr_unique_id = unique_id self._attr_device_info = device_info self._entity_id = entity_id - self._name = name + self._attr_name = name if lower is not None: self._threshold_lower = lower if upper is not None: self._threshold_upper = upper self.threshold_type = _threshold_type(lower, upper) self._hysteresis: float = hysteresis - self._device_class = device_class + self._attr_device_class = device_class self._state_position = POSITION_UNKNOWN self._state: bool | None = None self.sensor_value: float | None = None @@ -227,21 +227,11 @@ def async_threshold_sensor_state_listener( ) _update_sensor_state() - @property - def name(self) -> str: - """Return the name of the sensor.""" - return self._name - @property def is_on(self) -> bool | None: """Return true if sensor is on.""" return self._state - @property - def device_class(self) -> BinarySensorDeviceClass | None: - """Return the sensor class of the sensor.""" - return self._device_class - @property def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes of the sensor.""" diff --git a/homeassistant/components/tibber/manifest.json b/homeassistant/components/tibber/manifest.json index c668430914fae7..1d8120a4321e38 100644 --- a/homeassistant/components/tibber/manifest.json +++ b/homeassistant/components/tibber/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["tibber"], "quality_scale": "silver", - "requirements": ["pyTibber==0.28.0"] + "requirements": ["pyTibber==0.28.2"] } diff --git a/homeassistant/components/tibber/strings.json b/homeassistant/components/tibber/strings.json index 2876bf5bd022c3..8306f25f587afd 100644 --- a/homeassistant/components/tibber/strings.json +++ b/homeassistant/components/tibber/strings.json @@ -4,7 +4,7 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" }, "error": { - "timeout": "Timeout connecting to Tibber", + "timeout": "[%key:common::config_flow::error::timeout_connect%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]" }, diff --git a/homeassistant/components/timer/__init__.py b/homeassistant/components/timer/__init__.py index 1bc8eb8fd5ef70..228e2071b4a194 100644 --- a/homeassistant/components/timer/__init__.py +++ b/homeassistant/components/timer/__init__.py @@ -22,7 +22,6 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import async_track_point_in_utc_time -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.restore_state import RestoreEntity import homeassistant.helpers.service from homeassistant.helpers.storage import Store @@ -304,18 +303,6 @@ async def async_added_to_hass(self): @callback def async_start(self, duration: timedelta | None = None): """Start a timer.""" - if duration: - async_create_issue( - self.hass, - DOMAIN, - "deprecated_duration_in_start", - breaks_in_ha_version="2024.3.0", - is_fixable=True, - is_persistent=True, - severity=IssueSeverity.WARNING, - translation_key="deprecated_duration_in_start", - ) - if self._listener: self._listener() self._listener = None diff --git a/homeassistant/components/timer/services.yaml b/homeassistant/components/timer/services.yaml index 74eeae22b23051..ac5453d38c913d 100644 --- a/homeassistant/components/timer/services.yaml +++ b/homeassistant/components/timer/services.yaml @@ -36,3 +36,5 @@ change: example: "00:01:00, 60 or -60" selector: text: + +reload: diff --git a/homeassistant/components/timer/strings.json b/homeassistant/components/timer/strings.json index c85a9f4c55e156..1ebf0c6f50a435 100644 --- a/homeassistant/components/timer/strings.json +++ b/homeassistant/components/timer/strings.json @@ -62,19 +62,10 @@ "description": "Duration to add or subtract to the running timer." } } - } - }, - "issues": { - "deprecated_duration_in_start": { - "title": "The timer start service duration parameter is being removed", - "fix_flow": { - "step": { - "confirm": { - "title": "[%key:component::timer::issues::deprecated_duration_in_start::title%]", - "description": "The timer service `timer.start` optional duration parameter is being removed and use of it has been detected. To change the duration please create a new timer.\n\nPlease remove the use of the `duration` parameter in the `timer.start` service in your automations and scripts and select **submit** to close this issue." - } - } - } + }, + "reload": { + "name": "[%key:common::action::reload%]", + "description": "Reloads timers from the YAML-configuration." } } } diff --git a/homeassistant/components/todoist/__init__.py b/homeassistant/components/todoist/__init__.py index 78a9cb89624664..12b75a40bae256 100644 --- a/homeassistant/components/todoist/__init__.py +++ b/homeassistant/components/todoist/__init__.py @@ -1 +1,44 @@ -"""The todoist component.""" +"""The todoist integration.""" + +import datetime +import logging + +from todoist_api_python.api_async import TodoistAPIAsync + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_TOKEN, Platform +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import TodoistCoordinator + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = datetime.timedelta(minutes=1) + + +PLATFORMS: list[Platform] = [Platform.CALENDAR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up todoist from a config entry.""" + + token = entry.data[CONF_TOKEN] + api = TodoistAPIAsync(token) + coordinator = TodoistCoordinator(hass, _LOGGER, SCAN_INTERVAL, api, token) + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/todoist/calendar.py b/homeassistant/components/todoist/calendar.py index 544144018dd9d6..40ceb71ee5fd48 100644 --- a/homeassistant/components/todoist/calendar.py +++ b/homeassistant/components/todoist/calendar.py @@ -17,8 +17,10 @@ CalendarEntity, CalendarEvent, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ID, CONF_NAME, CONF_TOKEN, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -106,6 +108,23 @@ SCAN_INTERVAL = timedelta(minutes=1) +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Todoist calendar platform config entry.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + projects = await coordinator.async_get_projects() + labels = await coordinator.async_get_labels() + + entities = [] + for project in projects: + project_data: ProjectData = {CONF_NAME: project.name, CONF_ID: project.id} + entities.append(TodoistProjectEntity(coordinator, project_data, labels)) + + async_add_entities(entities) + async_register_services(hass, coordinator) + + async def async_setup_platform( hass: HomeAssistant, config: ConfigType, @@ -119,7 +138,7 @@ async def async_setup_platform( project_id_lookup = {} api = TodoistAPIAsync(token) - coordinator = TodoistCoordinator(hass, _LOGGER, SCAN_INTERVAL, api) + coordinator = TodoistCoordinator(hass, _LOGGER, SCAN_INTERVAL, api, token) await coordinator.async_refresh() async def _shutdown_coordinator(_: Event) -> None: @@ -177,12 +196,29 @@ async def _shutdown_coordinator(_: Event) -> None: async_add_entities(project_devices, update_before_add=True) + async_register_services(hass, coordinator) + + +def async_register_services( + hass: HomeAssistant, coordinator: TodoistCoordinator +) -> None: + """Register services.""" + + if hass.services.has_service(DOMAIN, SERVICE_NEW_TASK): + return + session = async_get_clientsession(hass) async def handle_new_task(call: ServiceCall) -> None: """Call when a user creates a new Todoist Task from Home Assistant.""" - project_name = call.data[PROJECT_NAME] - project_id = project_id_lookup[project_name] + project_name = call.data[PROJECT_NAME].lower() + projects = await coordinator.async_get_projects() + project_id: str | None = None + for project in projects: + if project_name == project.name.lower(): + project_id = project.id + if project_id is None: + raise HomeAssistantError(f"Invalid project name '{project_name}'") # Create the task content = call.data[CONTENT] @@ -192,7 +228,7 @@ async def handle_new_task(call: ServiceCall) -> None: data["labels"] = task_labels if ASSIGNEE in call.data: - collaborators = await api.get_collaborators(project_id) + collaborators = await coordinator.api.get_collaborators(project_id) collaborator_id_lookup = { collab.name.lower(): collab.id for collab in collaborators } @@ -225,7 +261,7 @@ async def handle_new_task(call: ServiceCall) -> None: date_format = "%Y-%m-%dT%H:%M:%S" data["due_datetime"] = datetime.strftime(due_date, date_format) - api_task = await api.add_task(content, **data) + api_task = await coordinator.api.add_task(content, **data) # @NOTE: The rest-api doesn't support reminders, this works manually using # the sync api, in order to keep functional parity with the component. @@ -263,7 +299,7 @@ async def add_reminder(reminder_due: dict): } ] } - headers = create_headers(token=token, with_content=True) + headers = create_headers(token=coordinator.token, with_content=True) return await session.post(sync_url, headers=headers, json=reminder_data) if _reminder_due: diff --git a/homeassistant/components/todoist/config_flow.py b/homeassistant/components/todoist/config_flow.py new file mode 100644 index 00000000000000..6098df40ea047f --- /dev/null +++ b/homeassistant/components/todoist/config_flow.py @@ -0,0 +1,61 @@ +"""Config flow for todoist integration.""" + +from http import HTTPStatus +import logging +from typing import Any + +from requests.exceptions import HTTPError +from todoist_api_python.api_async import TodoistAPIAsync +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_TOKEN +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +SETTINGS_URL = "https://todoist.com/app/settings/integrations" + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_TOKEN): str, + } +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for todoist.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + + errors: dict[str, str] = {} + if user_input is not None: + api = TodoistAPIAsync(user_input[CONF_TOKEN]) + try: + await api.get_tasks() + except HTTPError as err: + if err.response.status_code == HTTPStatus.UNAUTHORIZED: + errors["base"] = "invalid_access_token" + else: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_create_entry(title="Todoist", data=user_input) + + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors=errors, + description_placeholders={"settings_url": SETTINGS_URL}, + ) diff --git a/homeassistant/components/todoist/coordinator.py b/homeassistant/components/todoist/coordinator.py index b573d1d11277cb..702c43883ea41c 100644 --- a/homeassistant/components/todoist/coordinator.py +++ b/homeassistant/components/todoist/coordinator.py @@ -3,7 +3,7 @@ import logging from todoist_api_python.api_async import TodoistAPIAsync -from todoist_api_python.models import Task +from todoist_api_python.models import Label, Project, Task from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -18,10 +18,14 @@ def __init__( logger: logging.Logger, update_interval: timedelta, api: TodoistAPIAsync, + token: str, ) -> None: """Initialize the Todoist coordinator.""" super().__init__(hass, logger, name="Todoist", update_interval=update_interval) self.api = api + self._projects: list[Project] | None = None + self._labels: list[Label] | None = None + self.token = token async def _async_update_data(self) -> list[Task]: """Fetch tasks from the Todoist API.""" @@ -29,3 +33,15 @@ async def _async_update_data(self) -> list[Task]: return await self.api.get_tasks() except Exception as err: raise UpdateFailed(f"Error communicating with API: {err}") from err + + async def async_get_projects(self) -> list[Project]: + """Return todoist projects fetched at most once.""" + if self._projects is None: + self._projects = await self.api.get_projects() + return self._projects + + async def async_get_labels(self) -> list[Label]: + """Return todoist labels fetched at most once.""" + if self._labels is None: + self._labels = await self.api.get_labels() + return self._labels diff --git a/homeassistant/components/todoist/manifest.json b/homeassistant/components/todoist/manifest.json index a83cdbe1b09fd3..72d76108353f13 100644 --- a/homeassistant/components/todoist/manifest.json +++ b/homeassistant/components/todoist/manifest.json @@ -2,6 +2,7 @@ "domain": "todoist", "name": "Todoist", "codeowners": ["@boralyl"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/todoist", "iot_class": "cloud_polling", "loggers": ["todoist"], diff --git a/homeassistant/components/todoist/strings.json b/homeassistant/components/todoist/strings.json index 1ed092e5cf6e11..123b5d07ed77d3 100644 --- a/homeassistant/components/todoist/strings.json +++ b/homeassistant/components/todoist/strings.json @@ -1,4 +1,23 @@ { + "config": { + "step": { + "user": { + "data": { + "token": "[%key:common::config_flow::data::api_token%]" + }, + "description": "Please entry your API token from your [Todoist Settings page]({settings_url})" + } + }, + "error": { + "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + }, + "create_entry": { + "default": "[%key:common::config_flow::create_entry::authenticated%]" + } + }, "services": { "new_task": { "name": "New task", diff --git a/homeassistant/components/tomorrowio/__init__.py b/homeassistant/components/tomorrowio/__init__.py index 41fa8158624ec8..626049276f5988 100644 --- a/homeassistant/components/tomorrowio/__init__.py +++ b/homeassistant/components/tomorrowio/__init__.py @@ -221,7 +221,6 @@ async def async_setup_entry(self, entry: ConfigEntry) -> None: await self.async_refresh() self.update_interval = async_set_update_interval(self.hass, self._api) - self._next_refresh = None self._async_unsub_refresh() if self._listeners: self._schedule_refresh() @@ -302,6 +301,8 @@ async def _async_update_data(self) -> dict[str, Any]: [ TMRW_ATTR_TEMPERATURE_LOW, TMRW_ATTR_TEMPERATURE_HIGH, + TMRW_ATTR_DEW_POINT, + TMRW_ATTR_HUMIDITY, TMRW_ATTR_WIND_SPEED, TMRW_ATTR_WIND_DIRECTION, TMRW_ATTR_CONDITION, diff --git a/homeassistant/components/tomorrowio/sensor.py b/homeassistant/components/tomorrowio/sensor.py index 119a3dfe582732..cd48af8536a2ec 100644 --- a/homeassistant/components/tomorrowio/sensor.py +++ b/homeassistant/components/tomorrowio/sensor.py @@ -35,7 +35,6 @@ ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util import slugify from homeassistant.util.unit_conversion import DistanceConverter, SpeedConverter from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM @@ -80,6 +79,7 @@ class TomorrowioSensorEntityDescription(SensorEntityDescription): # restrict the type to str. name: str = "" + attribute: str = "" unit_imperial: str | None = None unit_metric: str | None = None multiplication_factor: Callable[[float], float] | float | None = None @@ -110,13 +110,15 @@ def convert_ppb_to_ugm3(molecular_weight: int | float) -> Callable[[float], floa SENSOR_TYPES = ( TomorrowioSensorEntityDescription( - key=TMRW_ATTR_FEELS_LIKE, + key="feels_like", + attribute=TMRW_ATTR_FEELS_LIKE, name="Feels Like", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, ), TomorrowioSensorEntityDescription( - key=TMRW_ATTR_DEW_POINT, + key="dew_point", + attribute=TMRW_ATTR_DEW_POINT, name="Dew Point", icon="mdi:thermometer-water", native_unit_of_measurement=UnitOfTemperature.CELSIUS, @@ -124,7 +126,8 @@ def convert_ppb_to_ugm3(molecular_weight: int | float) -> Callable[[float], floa ), # Data comes in as hPa TomorrowioSensorEntityDescription( - key=TMRW_ATTR_PRESSURE_SURFACE_LEVEL, + key="pressure_surface_level", + attribute=TMRW_ATTR_PRESSURE_SURFACE_LEVEL, name="Pressure (Surface Level)", native_unit_of_measurement=UnitOfPressure.HPA, device_class=SensorDeviceClass.PRESSURE, @@ -132,7 +135,8 @@ def convert_ppb_to_ugm3(molecular_weight: int | float) -> Callable[[float], floa # Data comes in as W/m^2, convert to BTUs/(hr * ft^2) for imperial # https://www.theunitconverter.com/watt-square-meter-to-btu-hour-square-foot-conversion/ TomorrowioSensorEntityDescription( - key=TMRW_ATTR_SOLAR_GHI, + key="global_horizontal_irradiance", + attribute=TMRW_ATTR_SOLAR_GHI, name="Global Horizontal Irradiance", unit_imperial=UnitOfIrradiance.BTUS_PER_HOUR_SQUARE_FOOT, unit_metric=UnitOfIrradiance.WATTS_PER_SQUARE_METER, @@ -141,7 +145,8 @@ def convert_ppb_to_ugm3(molecular_weight: int | float) -> Callable[[float], floa ), # Data comes in as km, convert to miles for imperial TomorrowioSensorEntityDescription( - key=TMRW_ATTR_CLOUD_BASE, + key="cloud_base", + attribute=TMRW_ATTR_CLOUD_BASE, name="Cloud Base", icon="mdi:cloud-arrow-down", unit_imperial=UnitOfLength.MILES, @@ -154,7 +159,8 @@ def convert_ppb_to_ugm3(molecular_weight: int | float) -> Callable[[float], floa ), # Data comes in as km, convert to miles for imperial TomorrowioSensorEntityDescription( - key=TMRW_ATTR_CLOUD_CEILING, + key="cloud_ceiling", + attribute=TMRW_ATTR_CLOUD_CEILING, name="Cloud Ceiling", icon="mdi:cloud-arrow-up", unit_imperial=UnitOfLength.MILES, @@ -166,14 +172,16 @@ def convert_ppb_to_ugm3(molecular_weight: int | float) -> Callable[[float], floa ), ), TomorrowioSensorEntityDescription( - key=TMRW_ATTR_CLOUD_COVER, + key="cloud_cover", + attribute=TMRW_ATTR_CLOUD_COVER, name="Cloud Cover", icon="mdi:cloud-percent", native_unit_of_measurement=PERCENTAGE, ), # Data comes in as m/s, convert to mi/h for imperial TomorrowioSensorEntityDescription( - key=TMRW_ATTR_WIND_GUST, + key="wind_gust", + attribute=TMRW_ATTR_WIND_GUST, name="Wind Gust", icon="mdi:weather-windy", unit_imperial=UnitOfSpeed.MILES_PER_HOUR, @@ -183,7 +191,8 @@ def convert_ppb_to_ugm3(molecular_weight: int | float) -> Callable[[float], floa ), ), TomorrowioSensorEntityDescription( - key=TMRW_ATTR_PRECIPITATION_TYPE, + key="precipitation_type", + attribute=TMRW_ATTR_PRECIPITATION_TYPE, name="Precipitation Type", value_map=PrecipitationType, translation_key="precipitation_type", @@ -192,20 +201,23 @@ def convert_ppb_to_ugm3(molecular_weight: int | float) -> Callable[[float], floa # Data comes in as ppb, convert to µg/m^3 # Molecular weight of Ozone is 48 TomorrowioSensorEntityDescription( - key=TMRW_ATTR_OZONE, + key="ozone", + attribute=TMRW_ATTR_OZONE, name="Ozone", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, multiplication_factor=convert_ppb_to_ugm3(48), device_class=SensorDeviceClass.OZONE, ), TomorrowioSensorEntityDescription( - key=TMRW_ATTR_PARTICULATE_MATTER_25, + key="particulate_matter_2_5_mm", + attribute=TMRW_ATTR_PARTICULATE_MATTER_25, name="Particulate Matter < 2.5 μm", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, device_class=SensorDeviceClass.PM25, ), TomorrowioSensorEntityDescription( - key=TMRW_ATTR_PARTICULATE_MATTER_10, + key="particulate_matter_10_mm", + attribute=TMRW_ATTR_PARTICULATE_MATTER_10, name="Particulate Matter < 10 μm", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, device_class=SensorDeviceClass.PM10, @@ -213,7 +225,8 @@ def convert_ppb_to_ugm3(molecular_weight: int | float) -> Callable[[float], floa # Data comes in as ppb, convert to µg/m^3 # Molecular weight of Nitrogen Dioxide is 46.01 TomorrowioSensorEntityDescription( - key=TMRW_ATTR_NITROGEN_DIOXIDE, + key="nitrogen_dioxide", + attribute=TMRW_ATTR_NITROGEN_DIOXIDE, name="Nitrogen Dioxide", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, multiplication_factor=convert_ppb_to_ugm3(46.01), @@ -221,7 +234,8 @@ def convert_ppb_to_ugm3(molecular_weight: int | float) -> Callable[[float], floa ), # Data comes in as ppb, convert to ppm TomorrowioSensorEntityDescription( - key=TMRW_ATTR_CARBON_MONOXIDE, + key="carbon_monoxide", + attribute=TMRW_ATTR_CARBON_MONOXIDE, name="Carbon Monoxide", native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, multiplication_factor=1 / 1000, @@ -230,82 +244,95 @@ def convert_ppb_to_ugm3(molecular_weight: int | float) -> Callable[[float], floa # Data comes in as ppb, convert to µg/m^3 # Molecular weight of Sulphur Dioxide is 64.07 TomorrowioSensorEntityDescription( - key=TMRW_ATTR_SULPHUR_DIOXIDE, + key="sulphur_dioxide", + attribute=TMRW_ATTR_SULPHUR_DIOXIDE, name="Sulphur Dioxide", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, multiplication_factor=convert_ppb_to_ugm3(64.07), device_class=SensorDeviceClass.SULPHUR_DIOXIDE, ), TomorrowioSensorEntityDescription( - key=TMRW_ATTR_EPA_AQI, + key="us_epa_air_quality_index", + attribute=TMRW_ATTR_EPA_AQI, name="US EPA Air Quality Index", device_class=SensorDeviceClass.AQI, ), TomorrowioSensorEntityDescription( - key=TMRW_ATTR_EPA_PRIMARY_POLLUTANT, + key="us_epa_primary_pollutant", + attribute=TMRW_ATTR_EPA_PRIMARY_POLLUTANT, name="US EPA Primary Pollutant", value_map=PrimaryPollutantType, translation_key="primary_pollutant", ), TomorrowioSensorEntityDescription( - key=TMRW_ATTR_EPA_HEALTH_CONCERN, + key="us_epa_health_concern", + attribute=TMRW_ATTR_EPA_HEALTH_CONCERN, name="US EPA Health Concern", value_map=HealthConcernType, translation_key="health_concern", icon="mdi:hospital", ), TomorrowioSensorEntityDescription( - key=TMRW_ATTR_CHINA_AQI, + key="china_mep_air_quality_index", + attribute=TMRW_ATTR_CHINA_AQI, name="China MEP Air Quality Index", device_class=SensorDeviceClass.AQI, ), TomorrowioSensorEntityDescription( - key=TMRW_ATTR_CHINA_PRIMARY_POLLUTANT, + key="china_mep_primary_pollutant", + attribute=TMRW_ATTR_CHINA_PRIMARY_POLLUTANT, name="China MEP Primary Pollutant", value_map=PrimaryPollutantType, translation_key="primary_pollutant", ), TomorrowioSensorEntityDescription( - key=TMRW_ATTR_CHINA_HEALTH_CONCERN, + key="china_mep_health_concern", + attribute=TMRW_ATTR_CHINA_HEALTH_CONCERN, name="China MEP Health Concern", value_map=HealthConcernType, translation_key="health_concern", icon="mdi:hospital", ), TomorrowioSensorEntityDescription( - key=TMRW_ATTR_POLLEN_TREE, + key="tree_pollen_index", + attribute=TMRW_ATTR_POLLEN_TREE, name="Tree Pollen Index", icon="mdi:tree", value_map=PollenIndex, translation_key="pollen_index", ), TomorrowioSensorEntityDescription( - key=TMRW_ATTR_POLLEN_WEED, + key="weed_pollen_index", + attribute=TMRW_ATTR_POLLEN_WEED, name="Weed Pollen Index", value_map=PollenIndex, translation_key="pollen_index", icon="mdi:flower-pollen", ), TomorrowioSensorEntityDescription( - key=TMRW_ATTR_POLLEN_GRASS, + key="grass_pollen_index", + attribute=TMRW_ATTR_POLLEN_GRASS, name="Grass Pollen Index", icon="mdi:grass", value_map=PollenIndex, translation_key="pollen_index", ), TomorrowioSensorEntityDescription( - TMRW_ATTR_FIRE_INDEX, + key="fire_index", + attribute=TMRW_ATTR_FIRE_INDEX, name="Fire Index", icon="mdi:fire", ), TomorrowioSensorEntityDescription( - key=TMRW_ATTR_UV_INDEX, + key="uv_index", + attribute=TMRW_ATTR_UV_INDEX, name="UV Index", state_class=SensorStateClass.MEASUREMENT, icon="mdi:sun-wireless", ), TomorrowioSensorEntityDescription( - key=TMRW_ATTR_UV_HEALTH_CONCERN, + key="uv_radiation_health_concern", + attribute=TMRW_ATTR_UV_HEALTH_CONCERN, name="UV Radiation Health Concern", value_map=UVDescription, translation_key="uv_index", @@ -356,9 +383,7 @@ def __init__( super().__init__(config_entry, coordinator, api_version) self.entity_description = description self._attr_name = f"{self._config_entry.data[CONF_NAME]} - {description.name}" - self._attr_unique_id = ( - f"{self._config_entry.unique_id}_{slugify(description.name)}" - ) + self._attr_unique_id = f"{self._config_entry.unique_id}_{description.key}" if self.entity_description.native_unit_of_measurement is None: self._attr_native_unit_of_measurement = description.unit_metric if hass.config.units is US_CUSTOMARY_SYSTEM: @@ -403,6 +428,6 @@ class TomorrowioSensorEntity(BaseTomorrowioSensorEntity): @property def _state(self) -> int | float | None: """Return the raw state.""" - val = self._get_current_property(self.entity_description.key) + val = self._get_current_property(self.entity_description.attribute) assert not isinstance(val, str) return val diff --git a/homeassistant/components/tplink/entity.py b/homeassistant/components/tplink/entity.py index 890793b898d03d..afb341b47edea8 100644 --- a/homeassistant/components/tplink/entity.py +++ b/homeassistant/components/tplink/entity.py @@ -41,18 +41,14 @@ def __init__( super().__init__(coordinator) self.device: SmartDevice = device self._attr_unique_id = self.device.device_id - - @property - def device_info(self) -> DeviceInfo: - """Return information about the device.""" - return DeviceInfo( - connections={(dr.CONNECTION_NETWORK_MAC, self.device.mac)}, - identifiers={(DOMAIN, str(self.device.device_id))}, + self._attr_device_info = DeviceInfo( + connections={(dr.CONNECTION_NETWORK_MAC, device.mac)}, + identifiers={(DOMAIN, str(device.device_id))}, manufacturer="TP-Link", - model=self.device.model, - name=self.device.alias, - sw_version=self.device.hw_info["sw_ver"], - hw_version=self.device.hw_info["hw_ver"], + model=device.model, + name=device.alias, + sw_version=device.hw_info["sw_ver"], + hw_version=device.hw_info["hw_ver"], ) @property diff --git a/homeassistant/components/tplink_omada/entity.py b/homeassistant/components/tplink_omada/entity.py index bb330ef417a512..5008b7e4b18cb2 100644 --- a/homeassistant/components/tplink_omada/entity.py +++ b/homeassistant/components/tplink_omada/entity.py @@ -20,14 +20,10 @@ def __init__(self, coordinator: OmadaCoordinator[T], device: OmadaDevice) -> Non """Initialize the device.""" super().__init__(coordinator) self.device = device - - @property - def device_info(self) -> DeviceInfo: - """Return information about the device.""" - return DeviceInfo( - connections={(dr.CONNECTION_NETWORK_MAC, self.device.mac)}, - identifiers={(DOMAIN, (self.device.mac))}, + self._attr_device_info = DeviceInfo( + connections={(dr.CONNECTION_NETWORK_MAC, device.mac)}, + identifiers={(DOMAIN, device.mac)}, manufacturer="TP-Link", - model=self.device.model_display_name, - name=self.device.name, + model=device.model_display_name, + name=device.name, ) diff --git a/homeassistant/components/tplink_omada/manifest.json b/homeassistant/components/tplink_omada/manifest.json index 9c303b24661adf..3215a9ba77dc57 100644 --- a/homeassistant/components/tplink_omada/manifest.json +++ b/homeassistant/components/tplink_omada/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/tplink_omada", "integration_type": "hub", "iot_class": "local_polling", - "requirements": ["tplink_omada_client==1.3.2"] + "requirements": ["tplink-omada-client==1.3.2"] } diff --git a/homeassistant/components/tractive/device_tracker.py b/homeassistant/components/tractive/device_tracker.py index 0e373e1a44fabf..00296f3108c04b 100644 --- a/homeassistant/components/tractive/device_tracker.py +++ b/homeassistant/components/tractive/device_tracker.py @@ -99,6 +99,7 @@ def _handle_position_update(self, event: dict[str, Any]) -> None: self._attr_available = True self.async_write_ha_state() + # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" if not self._client.subscribed: diff --git a/homeassistant/components/tractive/manifest.json b/homeassistant/components/tractive/manifest.json index 9e448d1fd26e1a..75ddf065bd7178 100644 --- a/homeassistant/components/tractive/manifest.json +++ b/homeassistant/components/tractive/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "cloud_push", "loggers": ["aiotractive"], - "requirements": ["aiotractive==0.5.5"] + "requirements": ["aiotractive==0.5.6"] } diff --git a/homeassistant/components/tradfri/base_class.py b/homeassistant/components/tradfri/base_class.py index d186e19a2c8e87..416eb175d31df1 100644 --- a/homeassistant/components/tradfri/base_class.py +++ b/homeassistant/components/tradfri/base_class.py @@ -55,7 +55,16 @@ def __init__( self._device_id = self._device.id self._api = handle_error(api) - self._attr_unique_id = f"{self._gateway_id}-{self._device.id}" + info = self._device.device_info + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._device_id)}, + manufacturer=info.manufacturer, + model=info.model_number, + name=self._device.name, + sw_version=info.firmware_version, + via_device=(DOMAIN, gateway_id), + ) + self._attr_unique_id = f"{gateway_id}-{self._device_id}" @abstractmethod @callback @@ -71,19 +80,6 @@ def _handle_coordinator_update(self) -> None: self._refresh() super()._handle_coordinator_update() - @property - def device_info(self) -> DeviceInfo: - """Return the device info.""" - info = self._device.device_info - return DeviceInfo( - identifiers={(DOMAIN, self._device.id)}, - manufacturer=info.manufacturer, - model=info.model_number, - name=self._device.name, - sw_version=info.firmware_version, - via_device=(DOMAIN, self._gateway_id), - ) - @property def available(self) -> bool: """Return if entity is available.""" diff --git a/homeassistant/components/tradfri/config_flow.py b/homeassistant/components/tradfri/config_flow.py index 2a3052c1f7bd38..a383cc2bbee0a2 100644 --- a/homeassistant/components/tradfri/config_flow.py +++ b/homeassistant/components/tradfri/config_flow.py @@ -122,7 +122,7 @@ async def _entry_from_data(self, data: dict[str, Any]) -> FlowResult: if same_hub_entries: await asyncio.wait( [ - self.hass.config_entries.async_remove(entry_id) + asyncio.create_task(self.hass.config_entries.async_remove(entry_id)) for entry_id in same_hub_entries ] ) diff --git a/homeassistant/components/tradfri/fan.py b/homeassistant/components/tradfri/fan.py index a26dfa1d9a099c..c41b24a26473b7 100644 --- a/homeassistant/components/tradfri/fan.py +++ b/homeassistant/components/tradfri/fan.py @@ -56,6 +56,14 @@ class TradfriAirPurifierFan(TradfriBaseEntity, FanEntity): _attr_name = None _attr_supported_features = FanEntityFeature.PRESET_MODE | FanEntityFeature.SET_SPEED + _attr_preset_modes = [ATTR_AUTO] + # These are the steps: + # 0 = Off + # 1 = Preset: Auto mode + # 2 = Min + # ... with step size 1 + # 50 = Max + _attr_speed_count = ATTR_MAX_FAN_STEPS def __init__( self, @@ -77,19 +85,6 @@ def _refresh(self) -> None: """Refresh the device.""" self._device_data = self.coordinator.data.air_purifier_control.air_purifiers[0] - @property - def speed_count(self) -> int: - """Return the number of speeds the fan supports. - - These are the steps: - 0 = Off - 1 = Preset: Auto mode - 2 = Min - ... with step size 1 - 50 = Max - """ - return ATTR_MAX_FAN_STEPS - @property def is_on(self) -> bool: """Return true if switch is on.""" @@ -97,11 +92,6 @@ def is_on(self) -> bool: return False return cast(bool, self._device_data.state) - @property - def preset_modes(self) -> list[str] | None: - """Return a list of available preset modes.""" - return [ATTR_AUTO] - @property def percentage(self) -> int | None: """Return the current speed percentage.""" diff --git a/homeassistant/components/trafikverket_camera/__init__.py b/homeassistant/components/trafikverket_camera/__init__.py index dfac8416c49a1e..5575f32788a975 100644 --- a/homeassistant/components/trafikverket_camera/__init__.py +++ b/homeassistant/components/trafikverket_camera/__init__.py @@ -4,10 +4,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.integration_platform import ( - async_process_integration_platform_for_component, -) -from homeassistant.helpers.typing import ConfigType from .const import DOMAIN, PLATFORMS from .coordinator import TVDataUpdateCoordinator @@ -15,14 +11,6 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up trafikverket_camera.""" - # Process integration platforms right away since - # we will create entities before firing EVENT_COMPONENT_LOADED - await async_process_integration_platform_for_component(hass, DOMAIN) - return True - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Trafikverket Camera from a config entry.""" diff --git a/homeassistant/components/trafikverket_camera/binary_sensor.py b/homeassistant/components/trafikverket_camera/binary_sensor.py new file mode 100644 index 00000000000000..c9da5bd5d8ab9b --- /dev/null +++ b/homeassistant/components/trafikverket_camera/binary_sensor.py @@ -0,0 +1,69 @@ +"""Binary sensor platform for Trafikverket Camera integration.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from homeassistant.components.binary_sensor import ( + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import CameraData, TVDataUpdateCoordinator +from .entity import TrafikverketCameraNonCameraEntity + +PARALLEL_UPDATES = 0 + + +@dataclass +class DeviceBaseEntityDescriptionMixin: + """Mixin for required Trafikverket Camera base description keys.""" + + value_fn: Callable[[CameraData], bool | None] + + +@dataclass +class TVCameraSensorEntityDescription( + BinarySensorEntityDescription, DeviceBaseEntityDescriptionMixin +): + """Describes Trafikverket Camera binary sensor entity.""" + + +BINARY_SENSOR_TYPE = TVCameraSensorEntityDescription( + key="active", + translation_key="active", + icon="mdi:camera-outline", + value_fn=lambda data: data.data.active, +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up Trafikverket Camera binary sensor platform.""" + + coordinator: TVDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + [ + TrafikverketCameraBinarySensor( + coordinator, entry.entry_id, BINARY_SENSOR_TYPE + ) + ] + ) + + +class TrafikverketCameraBinarySensor( + TrafikverketCameraNonCameraEntity, BinarySensorEntity +): + """Representation of a Trafikverket Camera binary sensor.""" + + entity_description: TVCameraSensorEntityDescription + + @callback + def _update_attr(self) -> None: + """Update _attr.""" + self._attr_is_on = self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/trafikverket_camera/camera.py b/homeassistant/components/trafikverket_camera/camera.py index 936e460638f245..808d687a13123f 100644 --- a/homeassistant/components/trafikverket_camera/camera.py +++ b/homeassistant/components/trafikverket_camera/camera.py @@ -8,12 +8,11 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_LOCATION from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ATTR_DESCRIPTION, ATTR_TYPE, DOMAIN from .coordinator import TVDataUpdateCoordinator +from .entity import TrafikverketCameraEntity async def async_setup_entry( @@ -29,17 +28,17 @@ async def async_setup_entry( [ TVCamera( coordinator, - entry.title, entry.entry_id, ) ], ) -class TVCamera(CoordinatorEntity[TVDataUpdateCoordinator], Camera): +class TVCamera(TrafikverketCameraEntity, Camera): """Implement Trafikverket camera.""" - _attr_has_entity_name = True + _unrecorded_attributes = frozenset({ATTR_DESCRIPTION, ATTR_LOCATION}) + _attr_name = None _attr_translation_key = "tv_camera" coordinator: TVDataUpdateCoordinator @@ -47,21 +46,12 @@ class TVCamera(CoordinatorEntity[TVDataUpdateCoordinator], Camera): def __init__( self, coordinator: TVDataUpdateCoordinator, - name: str, entry_id: str, ) -> None: """Initialize the camera.""" - super().__init__(coordinator) + super().__init__(coordinator, entry_id) Camera.__init__(self) self._attr_unique_id = entry_id - self._attr_device_info = DeviceInfo( - entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, entry_id)}, - manufacturer="Trafikverket", - model="v1.0", - name=name, - configuration_url="https://api.trafikinfo.trafikverket.se/", - ) async def async_camera_image( self, width: int | None = None, height: int | None = None diff --git a/homeassistant/components/trafikverket_camera/config_flow.py b/homeassistant/components/trafikverket_camera/config_flow.py index b8a14a5424e394..e1f8220c4ff5c3 100644 --- a/homeassistant/components/trafikverket_camera/config_flow.py +++ b/homeassistant/components/trafikverket_camera/config_flow.py @@ -10,7 +10,7 @@ NoCameraFound, UnknownError, ) -from pytrafikverket.trafikverket_camera import TrafikverketCamera +from pytrafikverket.trafikverket_camera import CameraInfo, TrafikverketCamera import voluptuous as vol from homeassistant import config_entries @@ -29,14 +29,17 @@ class TVCameraConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): entry: config_entries.ConfigEntry | None - async def validate_input(self, sensor_api: str, location: str) -> dict[str, str]: + async def validate_input( + self, sensor_api: str, location: str + ) -> tuple[dict[str, str], str | None]: """Validate input from user input.""" errors: dict[str, str] = {} + camera_info: CameraInfo | None = None web_session = async_get_clientsession(self.hass) camera_api = TrafikverketCamera(web_session, sensor_api) try: - await camera_api.async_get_camera(location) + camera_info = await camera_api.async_get_camera(location) except NoCameraFound: errors["location"] = "invalid_location" except MultipleCamerasFound: @@ -46,7 +49,8 @@ async def validate_input(self, sensor_api: str, location: str) -> dict[str, str] except UnknownError: errors["base"] = "cannot_connect" - return errors + camera_location = camera_info.location if camera_info else None + return (errors, camera_location) async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle re-authentication with Trafikverket.""" @@ -58,13 +62,15 @@ async def async_step_reauth_confirm( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Confirm re-authentication with Trafikverket.""" - errors = {} + errors: dict[str, str] = {} if user_input: api_key = user_input[CONF_API_KEY] assert self.entry is not None - errors = await self.validate_input(api_key, self.entry.data[CONF_LOCATION]) + errors, _ = await self.validate_input( + api_key, self.entry.data[CONF_LOCATION] + ) if not errors: self.hass.config_entries.async_update_entry( @@ -91,22 +97,23 @@ async def async_step_user( self, user_input: dict[str, str] | None = None ) -> FlowResult: """Handle the initial step.""" - errors = {} + errors: dict[str, str] = {} if user_input: api_key = user_input[CONF_API_KEY] location = user_input[CONF_LOCATION] - errors = await self.validate_input(api_key, location) + errors, camera_location = await self.validate_input(api_key, location) if not errors: - await self.async_set_unique_id(f"{DOMAIN}-{location}") + assert camera_location + await self.async_set_unique_id(f"{DOMAIN}-{camera_location}") self._abort_if_unique_id_configured() return self.async_create_entry( - title=user_input[CONF_LOCATION], + title=camera_location, data={ CONF_API_KEY: api_key, - CONF_LOCATION: location, + CONF_LOCATION: camera_location, }, ) diff --git a/homeassistant/components/trafikverket_camera/const.py b/homeassistant/components/trafikverket_camera/const.py index 6657ab1a853d99..ff40d1bbc919e5 100644 --- a/homeassistant/components/trafikverket_camera/const.py +++ b/homeassistant/components/trafikverket_camera/const.py @@ -3,7 +3,7 @@ DOMAIN = "trafikverket_camera" CONF_LOCATION = "location" -PLATFORMS = [Platform.CAMERA] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.CAMERA, Platform.SENSOR] ATTRIBUTION = "Data provided by Trafikverket" ATTR_DESCRIPTION = "description" diff --git a/homeassistant/components/trafikverket_camera/entity.py b/homeassistant/components/trafikverket_camera/entity.py new file mode 100644 index 00000000000000..ec1d4d8f76bfe8 --- /dev/null +++ b/homeassistant/components/trafikverket_camera/entity.py @@ -0,0 +1,56 @@ +"""Base entity for Trafikverket Camera.""" +from __future__ import annotations + +from homeassistant.core import callback +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import TVDataUpdateCoordinator + + +class TrafikverketCameraEntity(CoordinatorEntity[TVDataUpdateCoordinator]): + """Base entity for Trafikverket Camera.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: TVDataUpdateCoordinator, + entry_id: str, + ) -> None: + """Initiate Trafikverket Camera Sensor.""" + super().__init__(coordinator) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, entry_id)}, + entry_type=DeviceEntryType.SERVICE, + manufacturer="Trafikverket", + model="v1.0", + configuration_url="https://api.trafikinfo.trafikverket.se/", + ) + + +class TrafikverketCameraNonCameraEntity(TrafikverketCameraEntity): + """Base entity for Trafikverket Camera but for non camera entities.""" + + def __init__( + self, + coordinator: TVDataUpdateCoordinator, + entry_id: str, + description: EntityDescription, + ) -> None: + """Initiate Trafikverket Camera Sensor.""" + super().__init__(coordinator, entry_id) + self._attr_unique_id = f"{entry_id}-{description.key}" + self.entity_description = description + self._update_attr() + + @callback + def _update_attr(self) -> None: + """Update _attr.""" + + @callback + def _handle_coordinator_update(self) -> None: + self._update_attr() + return super()._handle_coordinator_update() diff --git a/homeassistant/components/trafikverket_camera/manifest.json b/homeassistant/components/trafikverket_camera/manifest.json index 440d7237171f92..d23631c6878289 100644 --- a/homeassistant/components/trafikverket_camera/manifest.json +++ b/homeassistant/components/trafikverket_camera/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/trafikverket_camera", "iot_class": "cloud_polling", "loggers": ["pytrafikverket"], - "requirements": ["pytrafikverket==0.3.5"] + "requirements": ["pytrafikverket==0.3.6"] } diff --git a/homeassistant/components/trafikverket_camera/recorder.py b/homeassistant/components/trafikverket_camera/recorder.py deleted file mode 100644 index b6b608749ad172..00000000000000 --- a/homeassistant/components/trafikverket_camera/recorder.py +++ /dev/null @@ -1,13 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.const import ATTR_LOCATION -from homeassistant.core import HomeAssistant, callback - -from .const import ATTR_DESCRIPTION - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude description and location from being recorded in the database.""" - return {ATTR_DESCRIPTION, ATTR_LOCATION} diff --git a/homeassistant/components/trafikverket_camera/sensor.py b/homeassistant/components/trafikverket_camera/sensor.py new file mode 100644 index 00000000000000..96231bba755733 --- /dev/null +++ b/homeassistant/components/trafikverket_camera/sensor.py @@ -0,0 +1,109 @@ +"""Sensor platform for Trafikverket Camera integration.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import DEGREE +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .const import DOMAIN +from .coordinator import CameraData, TVDataUpdateCoordinator +from .entity import TrafikverketCameraNonCameraEntity + +PARALLEL_UPDATES = 0 + + +@dataclass +class DeviceBaseEntityDescriptionMixin: + """Mixin for required Trafikverket Camera base description keys.""" + + value_fn: Callable[[CameraData], StateType | datetime] + + +@dataclass +class TVCameraSensorEntityDescription( + SensorEntityDescription, DeviceBaseEntityDescriptionMixin +): + """Describes Trafikverket Camera sensor entity.""" + + +SENSOR_TYPES: tuple[TVCameraSensorEntityDescription, ...] = ( + TVCameraSensorEntityDescription( + key="direction", + translation_key="direction", + native_unit_of_measurement=DEGREE, + icon="mdi:sign-direction", + value_fn=lambda data: data.data.direction, + ), + TVCameraSensorEntityDescription( + key="modified", + translation_key="modified", + icon="mdi:camera-retake-outline", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda data: data.data.modified, + entity_registry_enabled_default=False, + ), + TVCameraSensorEntityDescription( + key="photo_time", + translation_key="photo_time", + icon="mdi:camera-timer", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda data: data.data.phototime, + ), + TVCameraSensorEntityDescription( + key="photo_url", + translation_key="photo_url", + icon="mdi:camera-outline", + value_fn=lambda data: data.data.photourl, + entity_registry_enabled_default=False, + ), + TVCameraSensorEntityDescription( + key="status", + translation_key="status", + icon="mdi:camera-outline", + value_fn=lambda data: data.data.status, + entity_registry_enabled_default=False, + ), + TVCameraSensorEntityDescription( + key="camera_type", + translation_key="camera_type", + icon="mdi:camera-iris", + value_fn=lambda data: data.data.camera_type, + entity_registry_enabled_default=False, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up Trafikverket Camera sensor platform.""" + + coordinator: TVDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + TrafikverketCameraSensor(coordinator, entry.entry_id, description) + for description in SENSOR_TYPES + ) + + +class TrafikverketCameraSensor(TrafikverketCameraNonCameraEntity, SensorEntity): + """Representation of a Trafikverket Camera Sensor.""" + + entity_description: TVCameraSensorEntityDescription + + @callback + def _update_attr(self) -> None: + """Update _attr.""" + self._attr_native_value = self.entity_description.value_fn( + self.coordinator.data + ) diff --git a/homeassistant/components/trafikverket_camera/strings.json b/homeassistant/components/trafikverket_camera/strings.json index c128f7729bcc7e..651225934cd587 100644 --- a/homeassistant/components/trafikverket_camera/strings.json +++ b/homeassistant/components/trafikverket_camera/strings.json @@ -46,6 +46,31 @@ } } } + }, + "binary_sensor": { + "active": { + "name": "Active" + } + }, + "sensor": { + "direction": { + "name": "Direction" + }, + "modified": { + "name": "Modified" + }, + "photo_time": { + "name": "Photo time" + }, + "photo_url": { + "name": "Photo url" + }, + "status": { + "name": "Status" + }, + "camera_type": { + "name": "Camera type" + } } } } diff --git a/homeassistant/components/trafikverket_ferry/manifest.json b/homeassistant/components/trafikverket_ferry/manifest.json index 47f1e62be00648..9d0b904290c100 100644 --- a/homeassistant/components/trafikverket_ferry/manifest.json +++ b/homeassistant/components/trafikverket_ferry/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/trafikverket_ferry", "iot_class": "cloud_polling", "loggers": ["pytrafikverket"], - "requirements": ["pytrafikverket==0.3.5"] + "requirements": ["pytrafikverket==0.3.6"] } diff --git a/homeassistant/components/trafikverket_train/manifest.json b/homeassistant/components/trafikverket_train/manifest.json index 47b4c21c867d0f..ab1f7feb3f7ef9 100644 --- a/homeassistant/components/trafikverket_train/manifest.json +++ b/homeassistant/components/trafikverket_train/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/trafikverket_train", "iot_class": "cloud_polling", "loggers": ["pytrafikverket"], - "requirements": ["pytrafikverket==0.3.5"] + "requirements": ["pytrafikverket==0.3.6"] } diff --git a/homeassistant/components/trafikverket_weatherstation/manifest.json b/homeassistant/components/trafikverket_weatherstation/manifest.json index 8c46afa597243d..138af544066934 100644 --- a/homeassistant/components/trafikverket_weatherstation/manifest.json +++ b/homeassistant/components/trafikverket_weatherstation/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/trafikverket_weatherstation", "iot_class": "cloud_polling", "loggers": ["pytrafikverket"], - "requirements": ["pytrafikverket==0.3.5"] + "requirements": ["pytrafikverket==0.3.6"] } diff --git a/homeassistant/components/trend/binary_sensor.py b/homeassistant/components/trend/binary_sensor.py index 020f7903060821..089e82b0f0759c 100644 --- a/homeassistant/components/trend/binary_sensor.py +++ b/homeassistant/components/trend/binary_sensor.py @@ -2,8 +2,10 @@ from __future__ import annotations from collections import deque +from collections.abc import Mapping import logging import math +from typing import Any import numpy as np import voluptuous as vol @@ -12,6 +14,7 @@ DEVICE_CLASSES_SCHEMA, ENTITY_ID_FORMAT, PLATFORM_SCHEMA, + BinarySensorDeviceClass, BinarySensorEntity, ) from homeassistant.const import ( @@ -117,55 +120,45 @@ class SensorTrend(BinarySensorEntity): """Representation of a trend Sensor.""" _attr_should_poll = False + _gradient = 0.0 + _state: bool | None = None def __init__( self, - hass, - device_id, - friendly_name, - entity_id, - attribute, - device_class, - invert, - max_samples, - min_gradient, - sample_duration, - ): + hass: HomeAssistant, + device_id: str, + friendly_name: str, + entity_id: str, + attribute: str, + device_class: BinarySensorDeviceClass, + invert: bool, + max_samples: int, + min_gradient: float, + sample_duration: int, + ) -> None: """Initialize the sensor.""" self._hass = hass self.entity_id = generate_entity_id(ENTITY_ID_FORMAT, device_id, hass=hass) - self._name = friendly_name + self._attr_name = friendly_name + self._attr_device_class = device_class self._entity_id = entity_id self._attribute = attribute - self._device_class = device_class self._invert = invert self._sample_duration = sample_duration self._min_gradient = min_gradient - self._gradient = None - self._state = None - self.samples = deque(maxlen=max_samples) + self.samples: deque = deque(maxlen=max_samples) @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def is_on(self): + def is_on(self) -> bool | None: """Return true if sensor is on.""" return self._state @property - def device_class(self): - """Return the sensor class of the sensor.""" - return self._device_class - - @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> Mapping[str, Any]: """Return the state attributes of the sensor.""" return { ATTR_ENTITY_ID: self._entity_id, - ATTR_FRIENDLY_NAME: self._name, + ATTR_FRIENDLY_NAME: self._attr_name, ATTR_GRADIENT: self._gradient, ATTR_INVERT: self._invert, ATTR_MIN_GRADIENT: self._min_gradient, @@ -224,7 +217,7 @@ async def async_update(self) -> None: if self._invert: self._state = not self._state - def _calculate_gradient(self): + def _calculate_gradient(self) -> None: """Compute the linear trend gradient of the current samples. This need run inside executor. diff --git a/homeassistant/components/trend/manifest.json b/homeassistant/components/trend/manifest.json index 77a0044ca1f93c..0adbf62334640d 100644 --- a/homeassistant/components/trend/manifest.json +++ b/homeassistant/components/trend/manifest.json @@ -1,9 +1,9 @@ { "domain": "trend", "name": "Trend", - "codeowners": [], + "codeowners": ["@jpbede"], "documentation": "https://www.home-assistant.io/integrations/trend", - "iot_class": "local_push", + "iot_class": "calculated", "quality_scale": "internal", - "requirements": ["numpy==1.23.2"] + "requirements": ["numpy==1.26.0"] } diff --git a/homeassistant/components/tts/manifest.json b/homeassistant/components/tts/manifest.json index 249e427c59132d..f1120ed2750046 100644 --- a/homeassistant/components/tts/manifest.json +++ b/homeassistant/components/tts/manifest.json @@ -8,5 +8,5 @@ "integration_type": "entity", "loggers": ["mutagen"], "quality_scale": "internal", - "requirements": ["mutagen==1.46.0"] + "requirements": ["mutagen==1.47.0"] } diff --git a/homeassistant/components/twinkly/diagnostics.py b/homeassistant/components/twinkly/diagnostics.py new file mode 100644 index 00000000000000..06afba5782bd2d --- /dev/null +++ b/homeassistant/components/twinkly/diagnostics.py @@ -0,0 +1,40 @@ +"""Diagnostics support for Twinkly.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_IP_ADDRESS, CONF_MAC +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .const import DATA_DEVICE_INFO, DOMAIN + +TO_REDACT = [CONF_HOST, CONF_IP_ADDRESS, CONF_MAC] + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a Twinkly config entry.""" + attributes = None + state = None + entity_registry = er.async_get(hass) + + entity_id = entity_registry.async_get_entity_id( + LIGHT_DOMAIN, DOMAIN, str(entry.unique_id) + ) + if entity_id: + state = hass.states.get(entity_id) + if state: + attributes = state.attributes + return async_redact_data( + { + "entry": entry.as_dict(), + "device_info": hass.data[DOMAIN][entry.entry_id][DATA_DEVICE_INFO], + "attributes": attributes, + }, + TO_REDACT, + ) diff --git a/homeassistant/components/twinkly/light.py b/homeassistant/components/twinkly/light.py index 5ddd22c8a23b14..66f764f17f6b4c 100644 --- a/homeassistant/components/twinkly/light.py +++ b/homeassistant/components/twinkly/light.py @@ -2,7 +2,6 @@ from __future__ import annotations import asyncio -from collections.abc import Mapping import logging from typing import Any @@ -62,6 +61,8 @@ async def async_setup_entry( class TwinklyLight(LightEntity): """Implementation of the light for the Twinkly service.""" + _attr_icon = "mdi:string-lights" + def __init__( self, conf: ConfigEntry, @@ -69,7 +70,7 @@ def __init__( device_info, ) -> None: """Initialize a TwinklyLight entity.""" - self._id = conf.data[CONF_ID] + self._attr_unique_id: str = conf.data[CONF_ID] self._conf = conf if device_info.get(DEV_LED_PROFILE) == DEV_PROFILE_RGBW: @@ -93,64 +94,30 @@ def __init__( self._client = client # Set default state before any update - self._is_on = False - self._is_available = False - self._attributes: dict[Any, Any] = {} + self._attr_is_on = False + self._attr_available = False self._current_movie: dict[Any, Any] = {} self._movies: list[Any] = [] self._software_version = "" # We guess that most devices are "new" and support effects self._attr_supported_features = LightEntityFeature.EFFECT - @property - def available(self) -> bool: - """Get a boolean which indicates if this entity is currently available.""" - return self._is_available - - @property - def unique_id(self) -> str | None: - """Id of the device.""" - return self._id - @property def name(self) -> str: """Name of the device.""" return self._name if self._name else "Twinkly light" - @property - def model(self) -> str: - """Name of the device.""" - return self._model - - @property - def icon(self) -> str: - """Icon of the device.""" - return "mdi:string-lights" - @property def device_info(self) -> DeviceInfo | None: """Get device specific attributes.""" return DeviceInfo( - identifiers={(DOMAIN, self._id)}, + identifiers={(DOMAIN, self._attr_unique_id)}, manufacturer="LEDWORKS", - model=self.model, + model=self._model, name=self.name, sw_version=self._software_version, ) - @property - def is_on(self) -> bool: - """Return true if light is on.""" - return self._is_on - - @property - def extra_state_attributes(self) -> Mapping[str, Any]: - """Return device specific state attributes.""" - - attributes = self._attributes - - return attributes - @property def effect(self) -> str | None: """Return the current effect.""" @@ -246,7 +213,7 @@ async def async_turn_on(self, **kwargs: Any) -> None: await self._client.set_current_movie(int(movie_id)) await self._client.set_mode("movie") self._client.default_mode = "movie" - if not self._is_on: + if not self._attr_is_on: await self._client.turn_on() async def async_turn_off(self, **kwargs: Any) -> None: @@ -258,7 +225,7 @@ async def async_update(self) -> None: _LOGGER.debug("Updating '%s'", self._client.host) try: - self._is_on = await self._client.is_on() + self._attr_is_on = await self._client.is_on() brightness = await self._client.get_brightness() brightness_value = ( @@ -266,7 +233,7 @@ async def async_update(self) -> None: ) self._attr_brightness = ( - int(round(brightness_value * 2.55)) if self._is_on else 0 + int(round(brightness_value * 2.55)) if self._attr_is_on else 0 ) device_info = await self._client.get_details() @@ -289,7 +256,7 @@ async def async_update(self) -> None: self._conf, data={ CONF_HOST: self._client.host, # this cannot change - CONF_ID: self._id, # this cannot change + CONF_ID: self._attr_unique_id, # this cannot change CONF_NAME: self._name, CONF_MODEL: self._model, }, @@ -299,20 +266,20 @@ async def async_update(self) -> None: await self.async_update_movies() await self.async_update_current_movie() - if not self._is_available: + if not self._attr_available: _LOGGER.info("Twinkly '%s' is now available", self._client.host) # We don't use the echo API to track the availability since # we already have to pull the device to get its state. - self._is_available = True + self._attr_available = True except (asyncio.TimeoutError, ClientError): # We log this as "info" as it's pretty common that the Christmas # light are not reachable in July - if self._is_available: + if self._attr_available: _LOGGER.info( "Twinkly '%s' is not reachable (client error)", self._client.host ) - self._is_available = False + self._attr_available = False async def async_update_movies(self) -> None: """Update the list of movies (effects).""" diff --git a/homeassistant/components/twinkly/manifest.json b/homeassistant/components/twinkly/manifest.json index 59deff915c391b..c6ab0bab893ad5 100644 --- a/homeassistant/components/twinkly/manifest.json +++ b/homeassistant/components/twinkly/manifest.json @@ -1,7 +1,7 @@ { "domain": "twinkly", "name": "Twinkly", - "codeowners": ["@dr1rrb", "@Robbie1221"], + "codeowners": ["@dr1rrb", "@Robbie1221", "@Olen"], "config_flow": true, "dhcp": [ { diff --git a/homeassistant/components/unifi/__init__.py b/homeassistant/components/unifi/__init__.py index 10959b8965c88e..0bde41ac61132d 100644 --- a/homeassistant/components/unifi/__init__.py +++ b/homeassistant/components/unifi/__init__.py @@ -6,7 +6,7 @@ from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import config_validation as cv, entity_registry as er +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType @@ -34,9 +34,6 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b """Set up the UniFi Network integration.""" hass.data.setdefault(UNIFI_DOMAIN, {}) - # Removal of legacy PoE control was introduced with 2022.12 - async_remove_poe_client_entities(hass, config_entry) - try: api = await get_unifi_controller(hass, config_entry.data) controller = UniFiController(hass, config_entry, api) @@ -74,24 +71,6 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> return await controller.async_reset() -@callback -def async_remove_poe_client_entities( - hass: HomeAssistant, config_entry: ConfigEntry -) -> None: - """Remove PoE client entities.""" - ent_reg = er.async_get(hass) - - entity_ids_to_be_removed = [ - entry.entity_id - for entry in ent_reg.entities.values() - if entry.config_entry_id == config_entry.entry_id - and entry.unique_id.startswith("poe-") - ] - - for entity_id in entity_ids_to_be_removed: - ent_reg.async_remove(entity_id) - - class UnifiWirelessClients: """Class to store clients known to be wireless. diff --git a/homeassistant/components/unifi/button.py b/homeassistant/components/unifi/button.py index 0235f6156cc3ad..7471675123aba7 100644 --- a/homeassistant/components/unifi/button.py +++ b/homeassistant/components/unifi/button.py @@ -24,7 +24,6 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN as UNIFI_DOMAIN from .controller import UniFiController from .entity import ( HandlerT, @@ -87,13 +86,13 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up button platform for UniFi Network integration.""" - controller: UniFiController = hass.data[UNIFI_DOMAIN][config_entry.entry_id] - - if not controller.is_admin: - return - - controller.register_platform_add_entities( - UnifiButtonEntity, ENTITY_DESCRIPTIONS, async_add_entities + UniFiController.register_platform( + hass, + config_entry, + async_add_entities, + UnifiButtonEntity, + ENTITY_DESCRIPTIONS, + requires_admin=True, ) diff --git a/homeassistant/components/unifi/controller.py b/homeassistant/components/unifi/controller.py index ba188f80135c97..9f965b424ffd39 100644 --- a/homeassistant/components/unifi/controller.py +++ b/homeassistant/components/unifi/controller.py @@ -21,14 +21,9 @@ CONF_PORT, CONF_USERNAME, CONF_VERIFY_SSL, - Platform, ) from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback -from homeassistant.helpers import ( - aiohttp_client, - device_registry as dr, - entity_registry as er, -) +from homeassistant.helpers import aiohttp_client, device_registry as dr from homeassistant.helpers.device_registry import ( DeviceEntry, DeviceEntryType, @@ -39,13 +34,11 @@ async_dispatcher_send, ) from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.entity_registry import async_entries_for_config_entry from homeassistant.helpers.event import async_call_later, async_track_time_interval import homeassistant.util.dt as dt_util from .const import ( ATTR_MANUFACTURER, - BLOCK_SWITCH, CONF_ALLOW_BANDWIDTH_SENSORS, CONF_ALLOW_UPTIME_SENSORS, CONF_BLOCK_CLIENT, @@ -162,6 +155,24 @@ def host(self) -> str: host: str = self.config_entry.data[CONF_HOST] return host + @callback + @staticmethod + def register_platform( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + entity_class: type[UnifiEntity], + descriptions: tuple[UnifiEntityDescription, ...], + requires_admin: bool = False, + ) -> None: + """Register platform for UniFi entity management.""" + controller: UniFiController = hass.data[UNIFI_DOMAIN][config_entry.entry_id] + if requires_admin and not controller.is_admin: + return + controller.register_platform_add_entities( + entity_class, descriptions, async_add_entities + ) + @callback def register_platform_add_entities( self, @@ -251,30 +262,9 @@ async def initialize(self) -> None: assert self.config_entry.unique_id is not None self.is_admin = self.api.sites[self.config_entry.unique_id].role == "admin" - # Restore clients that are not a part of active clients list. - entity_registry = er.async_get(self.hass) - for entry in async_entries_for_config_entry( - entity_registry, self.config_entry.entry_id - ): - if entry.domain == Platform.DEVICE_TRACKER: - mac = entry.unique_id.split("-", 1)[0] - elif entry.domain == Platform.SWITCH and entry.unique_id.startswith( - BLOCK_SWITCH - ): - mac = entry.unique_id.split("-", 1)[1] - else: - continue - - if mac in self.api.clients or mac not in self.api.clients_all: - continue - - client = self.api.clients_all[mac] - self.api.clients.process_raw([dict(client.raw)]) - LOGGER.debug( - "Restore disconnected client %s (%s)", - entry.entity_id, - client.mac, - ) + for mac in self.option_block_clients: + if mac not in self.api.clients and mac in self.api.clients_all: + self.api.clients.process_raw([dict(self.api.clients_all[mac].raw)]) self.wireless_clients.update_clients(set(self.api.clients.values())) diff --git a/homeassistant/components/unifi/device_tracker.py b/homeassistant/components/unifi/device_tracker.py index fcfe71a2858246..22a530e0369727 100644 --- a/homeassistant/components/unifi/device_tracker.py +++ b/homeassistant/components/unifi/device_tracker.py @@ -24,7 +24,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.dt as dt_util -from .const import DOMAIN as UNIFI_DOMAIN from .controller import UniFiController from .entity import ( HandlerT, @@ -139,7 +138,7 @@ class UnifiEntityTrackerDescriptionMixin(Generic[HandlerT, ApiItemT]): """Device tracker local functions.""" heartbeat_timedelta_fn: Callable[[UniFiController, str], timedelta] - ip_address_fn: Callable[[aiounifi.Controller, str], str] + ip_address_fn: Callable[[aiounifi.Controller, str], str | None] is_connected_fn: Callable[[UniFiController, str], bool] hostname_fn: Callable[[aiounifi.Controller, str], str | None] @@ -180,7 +179,6 @@ class UnifiTrackerEntityDescription( UnifiTrackerEntityDescription[Devices, Device]( key="Device scanner", has_entity_name=True, - icon="mdi:ethernet", allowed_fn=lambda controller, obj_id: controller.option_track_devices, api_handler_fn=lambda api: api.devices, available_fn=async_device_available_fn, @@ -206,9 +204,8 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up device tracker for UniFi Network integration.""" - controller: UniFiController = hass.data[UNIFI_DOMAIN][config_entry.entry_id] - controller.register_platform_add_entities( - UnifiScannerEntity, ENTITY_DESCRIPTIONS, async_add_entities + UniFiController.register_platform( + hass, config_entry, async_add_entities, UnifiScannerEntity, ENTITY_DESCRIPTIONS ) @@ -249,7 +246,7 @@ def hostname(self) -> str | None: return self.entity_description.hostname_fn(self.controller.api, self._obj_id) @property - def ip_address(self) -> str: + def ip_address(self) -> str | None: """Return the primary ip address of the device.""" return self.entity_description.ip_address_fn(self.controller.api, self._obj_id) diff --git a/homeassistant/components/unifi/image.py b/homeassistant/components/unifi/image.py index 8231b87ee85ba8..2318702f0d15b0 100644 --- a/homeassistant/components/unifi/image.py +++ b/homeassistant/components/unifi/image.py @@ -20,7 +20,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.dt as dt_util -from .const import DOMAIN as UNIFI_DOMAIN from .controller import UniFiController from .entity import ( HandlerT, @@ -83,13 +82,13 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up image platform for UniFi Network integration.""" - controller: UniFiController = hass.data[UNIFI_DOMAIN][config_entry.entry_id] - - if not controller.is_admin: - return - - controller.register_platform_add_entities( - UnifiImageEntity, ENTITY_DESCRIPTIONS, async_add_entities + UniFiController.register_platform( + hass, + config_entry, + async_add_entities, + UnifiImageEntity, + ENTITY_DESCRIPTIONS, + requires_admin=True, ) diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json index 363313bf878dcf..8734fd7dce555d 100644 --- a/homeassistant/components/unifi/manifest.json +++ b/homeassistant/components/unifi/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_push", "loggers": ["aiounifi"], "quality_scale": "platinum", - "requirements": ["aiounifi==58"], + "requirements": ["aiounifi==62"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py index 142bd587853a74..86c6b0d6352ca4 100644 --- a/homeassistant/components/unifi/sensor.py +++ b/homeassistant/components/unifi/sensor.py @@ -27,6 +27,7 @@ SensorDeviceClass, SensorEntity, SensorEntityDescription, + UnitOfTemperature, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, UnitOfInformation, UnitOfPower @@ -34,7 +35,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.dt as dt_util -from .const import DOMAIN as UNIFI_DOMAIN from .controller import UniFiController from .entity import ( HandlerT, @@ -88,6 +88,16 @@ def async_wlan_client_value_fn(controller: UniFiController, wlan: Wlan) -> int: ) +@callback +def async_device_uptime_value_fn( + controller: UniFiController, device: Device +) -> datetime: + """Calculate the uptime of the device.""" + return (dt_util.now() - timedelta(seconds=device.uptime)).replace( + second=0, microsecond=0 + ) + + @callback def async_device_outlet_power_supported_fn( controller: UniFiController, obj_id: str @@ -178,7 +188,7 @@ class UnifiSensorEntityDescription( value_fn=lambda _, obj: obj.poe_power if obj.poe_mode != "off" else "0", ), UnifiSensorEntityDescription[Clients, Client]( - key="Uptime sensor", + key="Client uptime", device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, has_entity_name=True, @@ -272,6 +282,43 @@ class UnifiSensorEntityDescription( unique_id_fn=lambda controller, obj_id: f"ac_power_conumption-{obj_id}", value_fn=lambda controller, device: device.outlet_ac_power_consumption, ), + UnifiSensorEntityDescription[Devices, Device]( + key="Device uptime", + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + has_entity_name=True, + allowed_fn=lambda controller, obj_id: True, + api_handler_fn=lambda api: api.devices, + available_fn=async_device_available_fn, + device_info_fn=async_device_device_info_fn, + event_is_on=None, + event_to_subscribe=None, + name_fn=lambda device: "Uptime", + object_fn=lambda api, obj_id: api.devices[obj_id], + should_poll=False, + supported_fn=lambda controller, obj_id: True, + unique_id_fn=lambda controller, obj_id: f"device_uptime-{obj_id}", + value_fn=async_device_uptime_value_fn, + ), + UnifiSensorEntityDescription[Devices, Device]( + key="Device temperature", + device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + has_entity_name=True, + allowed_fn=lambda controller, obj_id: True, + api_handler_fn=lambda api: api.devices, + available_fn=async_device_available_fn, + device_info_fn=async_device_device_info_fn, + event_is_on=None, + event_to_subscribe=None, + name_fn=lambda device: "Temperature", + object_fn=lambda api, obj_id: api.devices[obj_id], + should_poll=False, + supported_fn=lambda ctrlr, obj_id: ctrlr.api.devices[obj_id].has_temperature, + unique_id_fn=lambda controller, obj_id: f"device_temperature-{obj_id}", + value_fn=lambda ctrlr, device: device.general_temperature, + ), ) @@ -281,9 +328,8 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up sensors for UniFi Network integration.""" - controller: UniFiController = hass.data[UNIFI_DOMAIN][config_entry.entry_id] - controller.register_platform_add_entities( - UnifiSensorEntity, ENTITY_DESCRIPTIONS, async_add_entities + UniFiController.register_platform( + hass, config_entry, async_add_entities, UnifiSensorEntity, ENTITY_DESCRIPTIONS ) diff --git a/homeassistant/components/unifi/switch.py b/homeassistant/components/unifi/switch.py index 560e150e63c242..0aa399146868ef 100644 --- a/homeassistant/components/unifi/switch.py +++ b/homeassistant/components/unifi/switch.py @@ -43,7 +43,7 @@ from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ATTR_MANUFACTURER, DOMAIN as UNIFI_DOMAIN +from .const import ATTR_MANUFACTURER from .controller import UniFiController from .entity import ( HandlerT, @@ -320,19 +320,13 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up switches for UniFi Network integration.""" - controller: UniFiController = hass.data[UNIFI_DOMAIN][config_entry.entry_id] - - if not controller.is_admin: - return - - for mac in controller.option_block_clients: - if mac not in controller.api.clients and mac in controller.api.clients_all: - controller.api.clients.process_raw( - [dict(controller.api.clients_all[mac].raw)] - ) - - controller.register_platform_add_entities( - UnifiSwitchEntity, ENTITY_DESCRIPTIONS, async_add_entities + UniFiController.register_platform( + hass, + config_entry, + async_add_entities, + UnifiSwitchEntity, + ENTITY_DESCRIPTIONS, + requires_admin=True, ) diff --git a/homeassistant/components/unifi/update.py b/homeassistant/components/unifi/update.py index 6526a02da838ee..65b26736cf1d37 100644 --- a/homeassistant/components/unifi/update.py +++ b/homeassistant/components/unifi/update.py @@ -4,7 +4,7 @@ from collections.abc import Callable, Coroutine from dataclasses import dataclass import logging -from typing import TYPE_CHECKING, Any, Generic, TypeVar +from typing import Any, Generic, TypeVar import aiounifi from aiounifi.interfaces.api_handlers import ItemEvent @@ -21,7 +21,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN as UNIFI_DOMAIN +from .controller import UniFiController from .entity import ( UnifiEntity, UnifiEntityDescription, @@ -29,9 +29,6 @@ async_device_device_info_fn, ) -if TYPE_CHECKING: - from .controller import UniFiController - LOGGER = logging.getLogger(__name__) _DataT = TypeVar("_DataT", bound=Device) @@ -88,9 +85,12 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up update entities for UniFi Network integration.""" - controller: UniFiController = hass.data[UNIFI_DOMAIN][config_entry.entry_id] - controller.register_platform_add_entities( - UnifiDeviceUpdateEntity, ENTITY_DESCRIPTIONS, async_add_entities + UniFiController.register_platform( + hass, + config_entry, + async_add_entities, + UnifiDeviceUpdateEntity, + ENTITY_DESCRIPTIONS, ) diff --git a/homeassistant/components/unifiprotect/binary_sensor.py b/homeassistant/components/unifiprotect/binary_sensor.py index 668fe479e1f15d..8f8bcab8edeba4 100644 --- a/homeassistant/components/unifiprotect/binary_sensor.py +++ b/homeassistant/components/unifiprotect/binary_sensor.py @@ -621,3 +621,23 @@ def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: if not is_on: self._event = None self._attr_extra_state_attributes = {} + + @callback + def _async_updated_event(self, device: ProtectModelWithId) -> None: + """Call back for incoming data that only writes when state has changed. + + Only the is_on, _attr_extra_state_attributes, and available are ever + updated for these entities, and since the websocket update for the + device will trigger an update for all entities connected to the device, + we want to avoid writing state unless something has actually changed. + """ + previous_is_on = self._attr_is_on + previous_available = self._attr_available + previous_extra_state_attributes = self._attr_extra_state_attributes + self._async_update_device_from_protect(device) + if ( + self._attr_is_on != previous_is_on + or self._attr_extra_state_attributes != previous_extra_state_attributes + or self._attr_available != previous_available + ): + self.async_write_ha_state() diff --git a/homeassistant/components/unifiprotect/button.py b/homeassistant/components/unifiprotect/button.py index 3306743b7072af..bc93c1568662b7 100644 --- a/homeassistant/components/unifiprotect/button.py +++ b/homeassistant/components/unifiprotect/button.py @@ -193,3 +193,17 @@ async def async_press(self) -> None: if self.entity_description.ufp_press is not None: await getattr(self.device, self.entity_description.ufp_press)() + + @callback + def _async_updated_event(self, device: ProtectModelWithId) -> None: + """Call back for incoming data that only writes when state has changed. + + Only available is updated for these entities, and since the websocket + update for the device will trigger an update for all entities connected + to the device, we want to avoid writing state unless something has + actually changed. + """ + previous_available = self._attr_available + self._async_update_device_from_protect(device) + if self._attr_available != previous_available: + self.async_write_ha_state() diff --git a/homeassistant/components/unifiprotect/entity.py b/homeassistant/components/unifiprotect/entity.py index d42e611be7ed1d..28149d349c9aeb 100644 --- a/homeassistant/components/unifiprotect/entity.py +++ b/homeassistant/components/unifiprotect/entity.py @@ -311,6 +311,8 @@ def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: class EventEntityMixin(ProtectDeviceEntity): """Adds motion event attributes to sensor.""" + _unrecorded_attributes = frozenset({ATTR_EVENT_ID, ATTR_EVENT_SCORE}) + entity_description: ProtectEventMixin def __init__( diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index 5f2f58ce98aa88..b63700720e61e1 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -41,7 +41,7 @@ "iot_class": "local_push", "loggers": ["pyunifiprotect", "unifi_discovery"], "quality_scale": "platinum", - "requirements": ["pyunifiprotect==4.10.6", "unifi-discovery==1.1.7"], + "requirements": ["pyunifiprotect==4.20.0", "unifi-discovery==1.1.7"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/homeassistant/components/unifiprotect/media_player.py b/homeassistant/components/unifiprotect/media_player.py index c3f4e58e2471e8..df5ea40d4a912e 100644 --- a/homeassistant/components/unifiprotect/media_player.py +++ b/homeassistant/components/unifiprotect/media_player.py @@ -115,6 +115,26 @@ def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: ) self._attr_available = is_connected and updated_device.feature_flags.has_speaker + @callback + def _async_updated_event(self, device: ProtectModelWithId) -> None: + """Call back for incoming data that only writes when state has changed. + + Only the state, volume, and available are ever updated for these + entities, and since the websocket update for the device will trigger + an update for all entities connected to the device, we want to avoid + writing state unless something has actually changed. + """ + previous_state = self._attr_state + previous_available = self._attr_available + previous_volume_level = self._attr_volume_level + self._async_update_device_from_protect(device) + if ( + self._attr_state != previous_state + or self._attr_volume_level != previous_volume_level + or self._attr_available != previous_available + ): + self.async_write_ha_state() + async def async_set_volume_level(self, volume: float) -> None: """Set volume level, range 0..1.""" diff --git a/homeassistant/components/unifiprotect/number.py b/homeassistant/components/unifiprotect/number.py index 247e401b2ca14b..08bc9f385279bd 100644 --- a/homeassistant/components/unifiprotect/number.py +++ b/homeassistant/components/unifiprotect/number.py @@ -268,3 +268,21 @@ def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: async def async_set_native_value(self, value: float) -> None: """Set new value.""" await self.entity_description.ufp_set(self.device, value) + + @callback + def _async_updated_event(self, device: ProtectModelWithId) -> None: + """Call back for incoming data that only writes when state has changed. + + Only the native value and available are ever updated for these + entities, and since the websocket update for the device will trigger + an update for all entities connected to the device, we want to avoid + writing state unless something has actually changed. + """ + previous_value = self._attr_native_value + previous_available = self._attr_available + self._async_update_device_from_protect(device) + if ( + self._attr_native_value != previous_value + or self._attr_available != previous_available + ): + self.async_write_ha_state() diff --git a/homeassistant/components/unifiprotect/recorder.py b/homeassistant/components/unifiprotect/recorder.py deleted file mode 100644 index 6603a0543f881a..00000000000000 --- a/homeassistant/components/unifiprotect/recorder.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.core import HomeAssistant, callback - -from .const import ATTR_EVENT_ID, ATTR_EVENT_SCORE - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude event_id and event_score from being recorded in the database.""" - return {ATTR_EVENT_ID, ATTR_EVENT_SCORE} diff --git a/homeassistant/components/unifiprotect/select.py b/homeassistant/components/unifiprotect/select.py index 26a03fb7967ced..7605be17fc93ea 100644 --- a/homeassistant/components/unifiprotect/select.py +++ b/homeassistant/components/unifiprotect/select.py @@ -349,9 +349,9 @@ def __init__( description: ProtectSelectEntityDescription, ) -> None: """Initialize the unifi protect select entity.""" + self._async_set_options(data, description) super().__init__(data, device, description) self._attr_name = f"{self.device.display_name} {self.entity_description.name}" - self._async_set_options() @callback def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: @@ -366,31 +366,28 @@ def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: _LOGGER.debug( "Updating dynamic select options for %s", entity_description.name ) - self._async_set_options() + self._async_set_options(self.data, entity_description) + if (unifi_value := entity_description.get_ufp_value(device)) is None: + unifi_value = TYPE_EMPTY_VALUE + self._attr_current_option = self._unifi_to_hass_options.get( + unifi_value, unifi_value + ) @callback - def _async_set_options(self) -> None: + def _async_set_options( + self, data: ProtectData, description: ProtectSelectEntityDescription + ) -> None: """Set options attributes from UniFi Protect device.""" - - if self.entity_description.ufp_options is not None: - options = self.entity_description.ufp_options + if (ufp_options := description.ufp_options) is not None: + options = ufp_options else: - assert self.entity_description.ufp_options_fn is not None - options = self.entity_description.ufp_options_fn(self.data.api) + assert description.ufp_options_fn is not None + options = description.ufp_options_fn(data.api) self._attr_options = [item["name"] for item in options] self._hass_to_unifi_options = {item["name"]: item["id"] for item in options} self._unifi_to_hass_options = {item["id"]: item["name"] for item in options} - @property - def current_option(self) -> str: - """Return the current selected option.""" - - unifi_value = self.entity_description.get_ufp_value(self.device) - if unifi_value is None: - unifi_value = TYPE_EMPTY_VALUE - return self._unifi_to_hass_options.get(unifi_value, unifi_value) - async def async_select_option(self, option: str) -> None: """Change the Select Entity Option.""" @@ -404,3 +401,23 @@ async def async_select_option(self, option: str) -> None: if self.entity_description.ufp_enum_type is not None: unifi_value = self.entity_description.ufp_enum_type(unifi_value) await self.entity_description.ufp_set(self.device, unifi_value) + + @callback + def _async_updated_event(self, device: ProtectModelWithId) -> None: + """Call back for incoming data that only writes when state has changed. + + Only the options, option, and available are ever updated for these + entities, and since the websocket update for the device will trigger + an update for all entities connected to the device, we want to avoid + writing state unless something has actually changed. + """ + previous_option = self._attr_current_option + previous_options = self._attr_options + previous_available = self._attr_available + self._async_update_device_from_protect(device) + if ( + self._attr_current_option != previous_option + or self._attr_options != previous_options + or self._attr_available != previous_available + ): + self.async_write_ha_state() diff --git a/homeassistant/components/unifiprotect/sensor.py b/homeassistant/components/unifiprotect/sensor.py index d842b13b0151d3..756da49eb4d054 100644 --- a/homeassistant/components/unifiprotect/sensor.py +++ b/homeassistant/components/unifiprotect/sensor.py @@ -710,22 +710,56 @@ class ProtectDeviceSensor(ProtectDeviceEntity, SensorEntity): entity_description: ProtectSensorEntityDescription - @callback def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: super()._async_update_device_from_protect(device) self._attr_native_value = self.entity_description.get_ufp_value(self.device) + @callback + def _async_updated_event(self, device: ProtectModelWithId) -> None: + """Call back for incoming data that only writes when state has changed. + + Only the native value and available are ever updated for these + entities, and since the websocket update for the device will trigger + an update for all entities connected to the device, we want to avoid + writing state unless something has actually changed. + """ + previous_value = self._attr_native_value + previous_available = self._attr_available + self._async_update_device_from_protect(device) + if ( + self._attr_native_value != previous_value + or self._attr_available != previous_available + ): + self.async_write_ha_state() + class ProtectNVRSensor(ProtectNVREntity, SensorEntity): """A Ubiquiti UniFi Protect Sensor.""" entity_description: ProtectSensorEntityDescription - @callback def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: super()._async_update_device_from_protect(device) self._attr_native_value = self.entity_description.get_ufp_value(self.device) + @callback + def _async_updated_event(self, device: ProtectModelWithId) -> None: + """Call back for incoming data that only writes when state has changed. + + Only the native value and available are ever updated for these + entities, and since the websocket update for the device will trigger + an update for all entities connected to the device, we want to avoid + writing state unless something has actually changed. + """ + previous_value = self._attr_native_value + previous_available = self._attr_available + self._async_update_device_from_protect(device) + if ( + self._attr_native_value != previous_value + or self._attr_available != previous_available + ): + self.async_write_ha_state() + class ProtectEventSensor(EventEntityMixin, SensorEntity): """A UniFi Protect Device Sensor with access tokens.""" diff --git a/homeassistant/components/unifiprotect/switch.py b/homeassistant/components/unifiprotect/switch.py index ea2d8256cbe0d5..f1e6185b01024d 100644 --- a/homeassistant/components/unifiprotect/switch.py +++ b/homeassistant/components/unifiprotect/switch.py @@ -420,21 +420,36 @@ def __init__( self._attr_name = f"{self.device.display_name} {self.entity_description.name}" self._switch_type = self.entity_description.key - @property - def is_on(self) -> bool: - """Return true if device is on.""" - return self.entity_description.get_ufp_value(self.device) is True + def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: + super()._async_update_device_from_protect(device) + self._attr_is_on = self.entity_description.get_ufp_value(self.device) is True async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" - await self.entity_description.ufp_set(self.device, True) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" - await self.entity_description.ufp_set(self.device, False) + @callback + def _async_updated_event(self, device: ProtectModelWithId) -> None: + """Call back for incoming data that only writes when state has changed. + + Only the is_on and available are ever updated for these + entities, and since the websocket update for the device will trigger + an update for all entities connected to the device, we want to avoid + writing state unless something has actually changed. + """ + previous_is_on = self._attr_is_on + previous_available = self._attr_available + self._async_update_device_from_protect(device) + if ( + self._attr_is_on != previous_is_on + or self._attr_available != previous_available + ): + self.async_write_ha_state() + class ProtectNVRSwitch(ProtectNVREntity, SwitchEntity): """A UniFi Protect NVR Switch.""" diff --git a/homeassistant/components/universal/media_player.py b/homeassistant/components/universal/media_player.py index c221a10284a9fc..00f345fd248e83 100644 --- a/homeassistant/components/universal/media_player.py +++ b/homeassistant/components/universal/media_player.py @@ -40,7 +40,6 @@ SERVICE_PLAY_MEDIA, SERVICE_SELECT_SOUND_MODE, SERVICE_SELECT_SOURCE, - MediaPlayerDeviceClass, MediaPlayerEntity, MediaPlayerEntityFeature, MediaPlayerState, @@ -177,7 +176,7 @@ def __init__( self._child_state = None self._state_template_result = None self._state_template = config.get(CONF_STATE_TEMPLATE) - self._device_class = config.get(CONF_DEVICE_CLASS) + self._attr_device_class = config.get(CONF_DEVICE_CLASS) self._attr_unique_id = config.get(CONF_UNIQUE_ID) self._browse_media_entity = config.get(CONF_BROWSE_MEDIA_ENTITY) @@ -294,11 +293,6 @@ async def _async_call_service( DOMAIN, service_name, service_data, blocking=True, context=self._context ) - @property - def device_class(self) -> MediaPlayerDeviceClass | None: - """Return the class of this device.""" - return self._device_class - @property def master_state(self): """Return the master state for entity or None.""" diff --git a/homeassistant/components/upb/light.py b/homeassistant/components/upb/light.py index 4a71789423ffd7..50e6d50bb4cf65 100644 --- a/homeassistant/components/upb/light.py +++ b/homeassistant/components/upb/light.py @@ -57,7 +57,7 @@ class UpbLight(UpbAttachedEntity, LightEntity): def __init__(self, element, unique_id, upb): """Initialize an UpbLight.""" super().__init__(element, unique_id, upb) - self._brightness = self._element.status + self._attr_brightness: int = self._element.status @property def color_mode(self) -> ColorMode: @@ -78,15 +78,10 @@ def supported_features(self) -> LightEntityFeature: return LightEntityFeature.TRANSITION | LightEntityFeature.FLASH return LightEntityFeature.FLASH - @property - def brightness(self): - """Get the brightness.""" - return self._brightness - @property def is_on(self) -> bool: """Get the current brightness.""" - return self._brightness != 0 + return self._attr_brightness != 0 async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the light.""" @@ -123,4 +118,4 @@ async def async_update(self) -> None: def _element_changed(self, element, changeset): status = self._element.status - self._brightness = round(status * 2.55) if status else 0 + self._attr_brightness = round(status * 2.55) if status else 0 diff --git a/homeassistant/components/update/__init__.py b/homeassistant/components/update/__init__.py index e23032e24fe75d..c9496ce8f7bf77 100644 --- a/homeassistant/components/update/__init__.py +++ b/homeassistant/components/update/__init__.py @@ -13,7 +13,7 @@ from homeassistant.components import websocket_api from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_OFF, STATE_ON, EntityCategory +from homeassistant.const import ATTR_ENTITY_PICTURE, STATE_OFF, STATE_ON, EntityCategory from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv @@ -192,6 +192,10 @@ def _version_is_newer(latest_version: str, installed_version: str) -> bool: class UpdateEntity(RestoreEntity): """Representation of an update entity.""" + _entity_component_unrecorded_attributes = frozenset( + {ATTR_ENTITY_PICTURE, ATTR_IN_PROGRESS, ATTR_RELEASE_SUMMARY} + ) + entity_description: UpdateEntityDescription _attr_auto_update: bool = False _attr_installed_version: str | None = None diff --git a/homeassistant/components/update/recorder.py b/homeassistant/components/update/recorder.py deleted file mode 100644 index 408937c4f3159e..00000000000000 --- a/homeassistant/components/update/recorder.py +++ /dev/null @@ -1,13 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.const import ATTR_ENTITY_PICTURE -from homeassistant.core import HomeAssistant, callback - -from .const import ATTR_IN_PROGRESS, ATTR_RELEASE_SUMMARY - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude large and chatty update attributes from being recorded.""" - return {ATTR_ENTITY_PICTURE, ATTR_IN_PROGRESS, ATTR_RELEASE_SUMMARY} diff --git a/homeassistant/components/upnp/manifest.json b/homeassistant/components/upnp/manifest.json index 95bb3e779669ce..e42235af7479dd 100644 --- a/homeassistant/components/upnp/manifest.json +++ b/homeassistant/components/upnp/manifest.json @@ -8,7 +8,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["async_upnp_client"], - "requirements": ["async-upnp-client==0.35.0", "getmac==0.8.2"], + "requirements": ["async-upnp-client==0.35.1", "getmac==0.8.2"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:InternetGatewayDevice:1" diff --git a/homeassistant/components/uptimerobot/__init__.py b/homeassistant/components/uptimerobot/__init__.py index 3cb119837d72bd..58979d7defbff5 100644 --- a/homeassistant/components/uptimerobot/__init__.py +++ b/homeassistant/components/uptimerobot/__init__.py @@ -1,12 +1,7 @@ """The UptimeRobot integration.""" from __future__ import annotations -from pyuptimerobot import ( - UptimeRobot, - UptimeRobotAuthenticationException, - UptimeRobotException, - UptimeRobotMonitor, -) +from pyuptimerobot import UptimeRobot from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY @@ -14,9 +9,9 @@ from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import API_ATTR_OK, COORDINATOR_UPDATE_INTERVAL, DOMAIN, LOGGER, PLATFORMS +from .const import DOMAIN, PLATFORMS +from .coordinator import UptimeRobotDataUpdateCoordinator async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -51,64 +46,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -class UptimeRobotDataUpdateCoordinator(DataUpdateCoordinator[list[UptimeRobotMonitor]]): - """Data update coordinator for UptimeRobot.""" - - config_entry: ConfigEntry - - def __init__( - self, - hass: HomeAssistant, - config_entry_id: str, - dev_reg: dr.DeviceRegistry, - api: UptimeRobot, - ) -> None: - """Initialize coordinator.""" - super().__init__( - hass, - LOGGER, - name=DOMAIN, - update_interval=COORDINATOR_UPDATE_INTERVAL, - ) - self._config_entry_id = config_entry_id - self._device_registry = dev_reg - self.api = api - - async def _async_update_data(self) -> list[UptimeRobotMonitor]: - """Update data.""" - try: - response = await self.api.async_get_monitors() - except UptimeRobotAuthenticationException as exception: - raise ConfigEntryAuthFailed(exception) from exception - except UptimeRobotException as exception: - raise UpdateFailed(exception) from exception - - if response.status != API_ATTR_OK: - raise UpdateFailed(response.error.message) - - 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_id - ) - } - 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( - identifiers={(DOMAIN, monitor_id)} - ): - self._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}: - self.hass.async_create_task( - self.hass.config_entries.async_reload(self._config_entry_id) - ) - - return monitors diff --git a/homeassistant/components/uptimerobot/binary_sensor.py b/homeassistant/components/uptimerobot/binary_sensor.py index a4aeeb3151b1bc..2710d5166c20e4 100644 --- a/homeassistant/components/uptimerobot/binary_sensor.py +++ b/homeassistant/components/uptimerobot/binary_sensor.py @@ -10,8 +10,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import UptimeRobotDataUpdateCoordinator from .const import DOMAIN +from .coordinator import UptimeRobotDataUpdateCoordinator from .entity import UptimeRobotEntity diff --git a/homeassistant/components/uptimerobot/coordinator.py b/homeassistant/components/uptimerobot/coordinator.py new file mode 100644 index 00000000000000..4c1d3ea2c78288 --- /dev/null +++ b/homeassistant/components/uptimerobot/coordinator.py @@ -0,0 +1,78 @@ +"""DataUpdateCoordinator for the uptimerobot integration.""" +from __future__ import annotations + +from pyuptimerobot import ( + UptimeRobot, + UptimeRobotAuthenticationException, + UptimeRobotException, + UptimeRobotMonitor, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import API_ATTR_OK, COORDINATOR_UPDATE_INTERVAL, DOMAIN, LOGGER + + +class UptimeRobotDataUpdateCoordinator(DataUpdateCoordinator[list[UptimeRobotMonitor]]): + """Data update coordinator for UptimeRobot.""" + + config_entry: ConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry_id: str, + dev_reg: dr.DeviceRegistry, + api: UptimeRobot, + ) -> None: + """Initialize coordinator.""" + super().__init__( + hass, + LOGGER, + name=DOMAIN, + update_interval=COORDINATOR_UPDATE_INTERVAL, + ) + self._config_entry_id = config_entry_id + self._device_registry = dev_reg + self.api = api + + async def _async_update_data(self) -> list[UptimeRobotMonitor]: + """Update data.""" + try: + response = await self.api.async_get_monitors() + except UptimeRobotAuthenticationException as exception: + raise ConfigEntryAuthFailed(exception) from exception + except UptimeRobotException as exception: + raise UpdateFailed(exception) from exception + + if response.status != API_ATTR_OK: + raise UpdateFailed(response.error.message) + + 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_id + ) + } + 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( + identifiers={(DOMAIN, monitor_id)} + ): + self._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}: + self.hass.async_create_task( + self.hass.config_entries.async_reload(self._config_entry_id) + ) + + return monitors diff --git a/homeassistant/components/uptimerobot/diagnostics.py b/homeassistant/components/uptimerobot/diagnostics.py index 94710235ab751b..15173a5e43cd77 100644 --- a/homeassistant/components/uptimerobot/diagnostics.py +++ b/homeassistant/components/uptimerobot/diagnostics.py @@ -8,8 +8,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from . import UptimeRobotDataUpdateCoordinator from .const import DOMAIN +from .coordinator import UptimeRobotDataUpdateCoordinator async def async_get_config_entry_diagnostics( diff --git a/homeassistant/components/uptimerobot/sensor.py b/homeassistant/components/uptimerobot/sensor.py index f9d4097fe4034a..4ae40bf4134205 100644 --- a/homeassistant/components/uptimerobot/sensor.py +++ b/homeassistant/components/uptimerobot/sensor.py @@ -13,8 +13,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import UptimeRobotDataUpdateCoordinator from .const import DOMAIN +from .coordinator import UptimeRobotDataUpdateCoordinator from .entity import UptimeRobotEntity diff --git a/homeassistant/components/uptimerobot/switch.py b/homeassistant/components/uptimerobot/switch.py index 397d2085357f80..3406c9fe21a757 100644 --- a/homeassistant/components/uptimerobot/switch.py +++ b/homeassistant/components/uptimerobot/switch.py @@ -14,8 +14,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import UptimeRobotDataUpdateCoordinator from .const import API_ATTR_OK, DOMAIN, LOGGER +from .coordinator import UptimeRobotDataUpdateCoordinator from .entity import UptimeRobotEntity diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index f3e86136f5d4b8..cd581d8c37f10c 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -17,6 +17,7 @@ SensorExtraStoredData, SensorStateClass, ) +from homeassistant.components.sensor.recorder import _suggest_report_issue from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, @@ -484,6 +485,12 @@ def async_reading(self, event: EventType[EventStateChangedData]) -> None: DATA_TARIFF_SENSORS ]: sensor.start(new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)) + if self._unit_of_measurement is None: + _LOGGER.warning( + "Source sensor %s has no unit of measurement. Please %s", + self._sensor_source_id, + _suggest_report_issue(self.hass, self._sensor_source_id), + ) if ( adjustment := self.calculate_adjustment(old_state, new_state) @@ -491,6 +498,7 @@ def async_reading(self, event: EventType[EventStateChangedData]) -> None: # If net_consumption is off, the adjustment must be non-negative self._state += adjustment # type: ignore[operator] # self._state will be set to by the start function if it is None, therefore it always has a valid Decimal value at this line + self._unit_of_measurement = new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) self._last_valid_state = new_state_val self.async_write_ha_state() diff --git a/homeassistant/components/vacuum/__init__.py b/homeassistant/components/vacuum/__init__.py index 8285e1d76d1e66..68d50d1c2fc984 100644 --- a/homeassistant/components/vacuum/__init__.py +++ b/homeassistant/components/vacuum/__init__.py @@ -228,6 +228,8 @@ class _BaseVacuum(Entity): Contains common properties and functions for all vacuum devices. """ + _entity_component_unrecorded_attributes = frozenset({ATTR_FAN_SPEED_LIST}) + _attr_battery_icon: str _attr_battery_level: int | None = None _attr_fan_speed: str | None = None diff --git a/homeassistant/components/vacuum/recorder.py b/homeassistant/components/vacuum/recorder.py deleted file mode 100644 index 7dc7e9e0408feb..00000000000000 --- a/homeassistant/components/vacuum/recorder.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.core import HomeAssistant, callback - -from . import ATTR_FAN_SPEED_LIST - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude static attributes from being recorded in the database.""" - return {ATTR_FAN_SPEED_LIST} diff --git a/homeassistant/components/velux/__init__.py b/homeassistant/components/velux/__init__.py index 900453581365f8..b43ee39ed4e4d8 100644 --- a/homeassistant/components/velux/__init__.py +++ b/homeassistant/components/velux/__init__.py @@ -1,7 +1,7 @@ """Support for VELUX KLF 200 devices.""" import logging -from pyvlx import PyVLX, PyVLXException +from pyvlx import OpeningDevice, PyVLX, PyVLXException import voluptuous as vol from homeassistant.const import ( @@ -90,9 +90,11 @@ class VeluxEntity(Entity): _attr_should_poll = False - def __init__(self, node): + def __init__(self, node: OpeningDevice) -> None: """Initialize the Velux device.""" self.node = node + self._attr_unique_id = node.serial_number + self._attr_name = node.name if node.name else f"#{node.node_id}" @callback def async_register_callbacks(self): @@ -107,15 +109,3 @@ async def after_update_callback(device): async def async_added_to_hass(self): """Store register state change callback.""" self.async_register_callbacks() - - @property - def unique_id(self) -> str: - """Return the unique id base on the serial_id returned by Velux.""" - return self.node.serial_number - - @property - def name(self): - """Return the name of the Velux device.""" - if not self.node.name: - return "#" + str(self.node.node_id) - return self.node.name diff --git a/homeassistant/components/velux/cover.py b/homeassistant/components/velux/cover.py index c924fe5c10b862..48c09a2b3c21c5 100644 --- a/homeassistant/components/velux/cover.py +++ b/homeassistant/components/velux/cover.py @@ -39,6 +39,26 @@ async def async_setup_platform( class VeluxCover(VeluxEntity, CoverEntity): """Representation of a Velux cover.""" + _is_blind = False + + def __init__(self, node: OpeningDevice) -> None: + """Initialize VeluxCover.""" + super().__init__(node) + self._attr_device_class = CoverDeviceClass.WINDOW + if isinstance(node, Awning): + self._attr_device_class = CoverDeviceClass.AWNING + if isinstance(node, Blind): + self._attr_device_class = CoverDeviceClass.BLIND + self._is_blind = True + if isinstance(node, GarageDoor): + self._attr_device_class = CoverDeviceClass.GARAGE + if isinstance(node, Gate): + self._attr_device_class = CoverDeviceClass.GATE + if isinstance(node, RollerShutter): + self._attr_device_class = CoverDeviceClass.SHUTTER + if isinstance(node, Window): + self._attr_device_class = CoverDeviceClass.WINDOW + @property def supported_features(self) -> CoverEntityFeature: """Flag supported features.""" @@ -65,27 +85,10 @@ def current_cover_position(self) -> int: @property def current_cover_tilt_position(self) -> int | None: """Return the current position of the cover.""" - if isinstance(self.node, Blind): + if self._is_blind: return 100 - self.node.orientation.position_percent return None - @property - def device_class(self) -> CoverDeviceClass: - """Define this cover as either awning, blind, garage, gate, shutter or window.""" - if isinstance(self.node, Awning): - return CoverDeviceClass.AWNING - if isinstance(self.node, Blind): - return CoverDeviceClass.BLIND - if isinstance(self.node, GarageDoor): - return CoverDeviceClass.GARAGE - if isinstance(self.node, Gate): - return CoverDeviceClass.GATE - if isinstance(self.node, RollerShutter): - return CoverDeviceClass.SHUTTER - if isinstance(self.node, Window): - return CoverDeviceClass.WINDOW - return CoverDeviceClass.WINDOW - @property def is_closed(self) -> bool: """Return if the cover is closed.""" diff --git a/homeassistant/components/venstar/__init__.py b/homeassistant/components/venstar/__init__.py index a92d495f6af0ce..1416bcf376ad15 100644 --- a/homeassistant/components/venstar/__init__.py +++ b/homeassistant/components/venstar/__init__.py @@ -153,5 +153,5 @@ def device_info(self) -> DeviceInfo: name=self._client.name, manufacturer="Venstar", model=f"{self._client.model}-{self._client.get_type()}", - sw_version=self._client.get_api_ver(), + sw_version="{}.{}".format(*(self._client.get_firmware_ver())), ) diff --git a/homeassistant/components/venstar/manifest.json b/homeassistant/components/venstar/manifest.json index 39cbe0d35290dc..f3045fe49e8a70 100644 --- a/homeassistant/components/venstar/manifest.json +++ b/homeassistant/components/venstar/manifest.json @@ -1,7 +1,7 @@ { "domain": "venstar", "name": "Venstar", - "codeowners": ["@garbled1"], + "codeowners": ["@garbled1", "@jhollowe"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/venstar", "iot_class": "local_polling", diff --git a/homeassistant/components/vera/binary_sensor.py b/homeassistant/components/vera/binary_sensor.py index 57b47e6c742467..82c7d187b8882a 100644 --- a/homeassistant/components/vera/binary_sensor.py +++ b/homeassistant/components/vera/binary_sensor.py @@ -32,20 +32,16 @@ async def async_setup_entry( class VeraBinarySensor(VeraDevice[veraApi.VeraBinarySensor], BinarySensorEntity): """Representation of a Vera Binary Sensor.""" + _attr_is_on = False + def __init__( self, vera_device: veraApi.VeraBinarySensor, controller_data: ControllerData ) -> None: """Initialize the binary_sensor.""" - self._state = False VeraDevice.__init__(self, vera_device, controller_data) self.entity_id = ENTITY_ID_FORMAT.format(self.vera_id) - @property - def is_on(self) -> bool | None: - """Return true if sensor is on.""" - return self._state - def update(self) -> None: """Get the latest data and update the state.""" super().update() - self._state = self.vera_device.is_tripped + self._attr_is_on = self.vera_device.is_tripped diff --git a/homeassistant/components/vera/climate.py b/homeassistant/components/vera/climate.py index 164da079ac1391..f58ae083f72c61 100644 --- a/homeassistant/components/vera/climate.py +++ b/homeassistant/components/vera/climate.py @@ -46,6 +46,7 @@ class VeraThermostat(VeraDevice[veraApi.VeraThermostat], ClimateEntity): """Representation of a Vera Thermostat.""" _attr_hvac_modes = SUPPORT_HVAC + _attr_fan_modes = FAN_OPERATION_LIST _attr_supported_features = ( ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE ) @@ -79,11 +80,6 @@ def fan_mode(self) -> str | None: return FAN_ON return FAN_AUTO - @property - def fan_modes(self) -> list[str] | None: - """Return a list of available fan modes.""" - return FAN_OPERATION_LIST - def set_fan_mode(self, fan_mode: str) -> None: """Set new target temperature.""" if fan_mode == FAN_ON: diff --git a/homeassistant/components/vera/light.py b/homeassistant/components/vera/light.py index fa017be475e5e5..c76cd76ad194f1 100644 --- a/homeassistant/components/vera/light.py +++ b/homeassistant/components/vera/light.py @@ -41,31 +41,22 @@ async def async_setup_entry( class VeraLight(VeraDevice[veraApi.VeraDimmer], LightEntity): """Representation of a Vera Light, including dimmable.""" + _attr_is_on = False + _attr_hs_color: tuple[float, float] | None = None + _attr_brightness: int | None = None + def __init__( self, vera_device: veraApi.VeraDimmer, controller_data: ControllerData ) -> None: """Initialize the light.""" - self._state = False - self._color: tuple[float, float] | None = None - self._brightness = None VeraDevice.__init__(self, vera_device, controller_data) self.entity_id = ENTITY_ID_FORMAT.format(self.vera_id) - @property - def brightness(self) -> int | None: - """Return the brightness of the light.""" - return self._brightness - - @property - def hs_color(self) -> tuple[float, float] | None: - """Return the color of the light.""" - return self._color - @property def color_mode(self) -> ColorMode: """Return the color mode of the light.""" if self.vera_device.is_dimmable: - if self._color: + if self._attr_hs_color: return ColorMode.HS return ColorMode.BRIGHTNESS return ColorMode.ONOFF @@ -77,7 +68,7 @@ def supported_color_modes(self) -> set[ColorMode]: def turn_on(self, **kwargs: Any) -> None: """Turn the light on.""" - if ATTR_HS_COLOR in kwargs and self._color: + if ATTR_HS_COLOR in kwargs and self._attr_hs_color: rgb = color_util.color_hs_to_RGB(*kwargs[ATTR_HS_COLOR]) self.vera_device.set_color(rgb) elif ATTR_BRIGHTNESS in kwargs and self.vera_device.is_dimmable: @@ -85,27 +76,22 @@ def turn_on(self, **kwargs: Any) -> None: else: self.vera_device.switch_on() - self._state = True + self._attr_is_on = True self.schedule_update_ha_state(True) def turn_off(self, **kwargs: Any) -> None: """Turn the light off.""" self.vera_device.switch_off() - self._state = False + self._attr_is_on = False self.schedule_update_ha_state() - @property - def is_on(self) -> bool: - """Return true if device is on.""" - return self._state - def update(self) -> None: """Call to update state.""" super().update() - self._state = self.vera_device.is_switched_on() + self._attr_is_on = self.vera_device.is_switched_on() if self.vera_device.is_dimmable: # If it is dimmable, both functions exist. In case color # is not supported, it will return None - self._brightness = self.vera_device.get_brightness() + self._attr_brightness = self.vera_device.get_brightness() rgb = self.vera_device.get_color() - self._color = color_util.color_RGB_to_hs(*rgb) if rgb else None + self._attr_hs_color = color_util.color_RGB_to_hs(*rgb) if rgb else None diff --git a/homeassistant/components/vera/lock.py b/homeassistant/components/vera/lock.py index 50710030b8f2af..8994076ca312e8 100644 --- a/homeassistant/components/vera/lock.py +++ b/homeassistant/components/vera/lock.py @@ -7,7 +7,7 @@ from homeassistant.components.lock import ENTITY_ID_FORMAT, LockEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_LOCKED, STATE_UNLOCKED, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -41,24 +41,18 @@ def __init__( self, vera_device: veraApi.VeraLock, controller_data: ControllerData ) -> None: """Initialize the Vera device.""" - self._state: str | None = None VeraDevice.__init__(self, vera_device, controller_data) self.entity_id = ENTITY_ID_FORMAT.format(self.vera_id) def lock(self, **kwargs: Any) -> None: """Lock the device.""" self.vera_device.lock() - self._state = STATE_LOCKED + self._attr_is_locked = True def unlock(self, **kwargs: Any) -> None: """Unlock the device.""" self.vera_device.unlock() - self._state = STATE_UNLOCKED - - @property - def is_locked(self) -> bool | None: - """Return true if device is on.""" - return self._state == STATE_LOCKED + self._attr_is_locked = False @property def extra_state_attributes(self) -> dict[str, Any] | None: @@ -91,6 +85,4 @@ def changed_by(self) -> str | None: def update(self) -> None: """Update state by the Vera device callback.""" - self._state = ( - STATE_LOCKED if self.vera_device.is_locked(True) else STATE_UNLOCKED - ) + self._attr_is_locked = self.vera_device.is_locked(True) diff --git a/homeassistant/components/vera/scene.py b/homeassistant/components/vera/scene.py index c1381f488dd9ea..daa3a6fc530016 100644 --- a/homeassistant/components/vera/scene.py +++ b/homeassistant/components/vera/scene.py @@ -37,7 +37,7 @@ def __init__( self.vera_scene = vera_scene self.controller = controller_data.controller - self._name = self.vera_scene.name + self._attr_name = self.vera_scene.name # Append device id to prevent name clashes in HA. self.vera_id = VERA_ID_FORMAT.format( slugify(vera_scene.name), vera_scene.scene_id @@ -51,11 +51,6 @@ def activate(self, **kwargs: Any) -> None: """Activate the scene.""" self.vera_scene.activate() - @property - def name(self) -> str: - """Return the name of the scene.""" - return self._name - @property def extra_state_attributes(self) -> dict[str, Any] | None: """Return the state attributes of the scene.""" diff --git a/homeassistant/components/vera/sensor.py b/homeassistant/components/vera/sensor.py index b493f9aac3deb7..58e350bd034472 100644 --- a/homeassistant/components/vera/sensor.py +++ b/homeassistant/components/vera/sensor.py @@ -21,7 +21,6 @@ ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import StateType from . import VeraDevice from .common import ControllerData, get_controller_data @@ -52,78 +51,59 @@ def __init__( self, vera_device: veraApi.VeraSensor, controller_data: ControllerData ) -> None: """Initialize the sensor.""" - self.current_value: StateType = None self._temperature_units: str | None = None self.last_changed_time = None VeraDevice.__init__(self, vera_device, controller_data) self.entity_id = ENTITY_ID_FORMAT.format(self.vera_id) - - @property - def native_value(self) -> StateType: - """Return the name of the sensor.""" - return self.current_value - - @property - def device_class(self) -> SensorDeviceClass | None: - """Return the class of this entity.""" - if self.vera_device.category == veraApi.CATEGORY_TEMPERATURE_SENSOR: - return SensorDeviceClass.TEMPERATURE - if self.vera_device.category == veraApi.CATEGORY_LIGHT_SENSOR: - return SensorDeviceClass.ILLUMINANCE - if self.vera_device.category == veraApi.CATEGORY_HUMIDITY_SENSOR: - return SensorDeviceClass.HUMIDITY - if self.vera_device.category == veraApi.CATEGORY_POWER_METER: - return SensorDeviceClass.POWER - return None - - @property - def native_unit_of_measurement(self) -> str | None: - """Return the unit of measurement of this entity, if any.""" - if self.vera_device.category == veraApi.CATEGORY_TEMPERATURE_SENSOR: - return self._temperature_units + self._attr_device_class = SensorDeviceClass.TEMPERATURE + elif self.vera_device.category == veraApi.CATEGORY_LIGHT_SENSOR: + self._attr_device_class = SensorDeviceClass.ILLUMINANCE + elif self.vera_device.category == veraApi.CATEGORY_HUMIDITY_SENSOR: + self._attr_device_class = SensorDeviceClass.HUMIDITY + elif self.vera_device.category == veraApi.CATEGORY_POWER_METER: + self._attr_device_class = SensorDeviceClass.POWER if self.vera_device.category == veraApi.CATEGORY_LIGHT_SENSOR: - return LIGHT_LUX - if self.vera_device.category == veraApi.CATEGORY_UV_SENSOR: - return "level" - if self.vera_device.category == veraApi.CATEGORY_HUMIDITY_SENSOR: - return PERCENTAGE - if self.vera_device.category == veraApi.CATEGORY_POWER_METER: - return UnitOfPower.WATT - return None + self._attr_native_unit_of_measurement = LIGHT_LUX + elif self.vera_device.category == veraApi.CATEGORY_UV_SENSOR: + self._attr_native_unit_of_measurement = "level" + elif self.vera_device.category == veraApi.CATEGORY_HUMIDITY_SENSOR: + self._attr_native_unit_of_measurement = PERCENTAGE + elif self.vera_device.category == veraApi.CATEGORY_POWER_METER: + self._attr_native_unit_of_measurement = UnitOfPower.WATT def update(self) -> None: """Update the state.""" super().update() if self.vera_device.category == veraApi.CATEGORY_TEMPERATURE_SENSOR: - self.current_value = self.vera_device.temperature + self._attr_native_value = self.vera_device.temperature vera_temp_units = self.vera_device.vera_controller.temperature_units if vera_temp_units == "F": - self._temperature_units = UnitOfTemperature.FAHRENHEIT + self._attr_native_unit_of_measurement = UnitOfTemperature.FAHRENHEIT else: - self._temperature_units = UnitOfTemperature.CELSIUS + self._attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS elif self.vera_device.category == veraApi.CATEGORY_LIGHT_SENSOR: - self.current_value = self.vera_device.light + self._attr_native_value = self.vera_device.light elif self.vera_device.category == veraApi.CATEGORY_UV_SENSOR: - self.current_value = self.vera_device.light + self._attr_native_value = self.vera_device.light elif self.vera_device.category == veraApi.CATEGORY_HUMIDITY_SENSOR: - self.current_value = self.vera_device.humidity + self._attr_native_value = self.vera_device.humidity elif self.vera_device.category == veraApi.CATEGORY_SCENE_CONTROLLER: controller = cast(veraApi.VeraSceneController, self.vera_device) value = controller.get_last_scene_id(True) time = controller.get_last_scene_time(True) if time == self.last_changed_time: - self.current_value = None + self._attr_native_value = None else: - self.current_value = value + self._attr_native_value = value self.last_changed_time = time elif self.vera_device.category == veraApi.CATEGORY_POWER_METER: - self.current_value = self.vera_device.power + self._attr_native_value = self.vera_device.power elif self.vera_device.is_trippable: tripped = self.vera_device.is_tripped - self.current_value = "Tripped" if tripped else "Not Tripped" + self._attr_native_value = "Tripped" if tripped else "Not Tripped" else: - self.current_value = "Unknown" + self._attr_native_value = "Unknown" diff --git a/homeassistant/components/vera/switch.py b/homeassistant/components/vera/switch.py index b146ed39adefb1..011f777b1b2f04 100644 --- a/homeassistant/components/vera/switch.py +++ b/homeassistant/components/vera/switch.py @@ -34,32 +34,28 @@ async def async_setup_entry( class VeraSwitch(VeraDevice[veraApi.VeraSwitch], SwitchEntity): """Representation of a Vera Switch.""" + _attr_is_on = False + def __init__( self, vera_device: veraApi.VeraSwitch, controller_data: ControllerData ) -> None: """Initialize the Vera device.""" - self._state = False VeraDevice.__init__(self, vera_device, controller_data) self.entity_id = ENTITY_ID_FORMAT.format(self.vera_id) def turn_on(self, **kwargs: Any) -> None: """Turn device on.""" self.vera_device.switch_on() - self._state = True + self._attr_is_on = True self.schedule_update_ha_state() def turn_off(self, **kwargs: Any) -> None: """Turn device off.""" self.vera_device.switch_off() - self._state = False + self._attr_is_on = False self.schedule_update_ha_state() - @property - def is_on(self) -> bool: - """Return true if device is on.""" - return self._state - def update(self) -> None: """Update device state.""" super().update() - self._state = self.vera_device.is_switched_on() + self._attr_is_on = self.vera_device.is_switched_on() diff --git a/homeassistant/components/verisure/manifest.json b/homeassistant/components/verisure/manifest.json index 7c9e7057b0cfa6..70c0505929dbf0 100644 --- a/homeassistant/components/verisure/manifest.json +++ b/homeassistant/components/verisure/manifest.json @@ -1,7 +1,7 @@ { "domain": "verisure", "name": "Verisure", - "codeowners": ["@frenck", "@niro1987"], + "codeowners": ["@frenck"], "config_flow": true, "dhcp": [ { diff --git a/homeassistant/components/versasense/manifest.json b/homeassistant/components/versasense/manifest.json index 0dd63218939467..421a46bc2f6fde 100644 --- a/homeassistant/components/versasense/manifest.json +++ b/homeassistant/components/versasense/manifest.json @@ -1,7 +1,7 @@ { "domain": "versasense", "name": "VersaSense", - "codeowners": ["@flamm3blemuff1n"], + "codeowners": ["@imstevenxyz"], "documentation": "https://www.home-assistant.io/integrations/versasense", "iot_class": "local_polling", "loggers": ["pyversasense"], diff --git a/homeassistant/components/vesync/const.py b/homeassistant/components/vesync/const.py index b20a04b8a1c9e8..f87f1cf3a8a540 100644 --- a/homeassistant/components/vesync/const.py +++ b/homeassistant/components/vesync/const.py @@ -35,5 +35,10 @@ "Core600S": "Core600S", "LAP-C601S-WUS": "Core600S", # Alt ID Model Core600S "LAP-C601S-WUSR": "Core600S", # Alt ID Model Core600S - "LAP-C601S-WEU": "Core600S", # Alt ID Model Core600S + "LAP-C601S-WEU": "Core600S", # Alt ID Model Core600S, + "LAP-V201S-AASR": "Vital200S", + "LAP-V201S-WJP": "Vital200S", + "LAP-V201S-WEU": "Vital200S", + "LAP-V201S-WUS": "Vital200S", + "LAP-V201-AUSR": "Vital200S", } diff --git a/homeassistant/components/vesync/fan.py b/homeassistant/components/vesync/fan.py index e5347b204e672d..87934ced81fe02 100644 --- a/homeassistant/components/vesync/fan.py +++ b/homeassistant/components/vesync/fan.py @@ -27,10 +27,12 @@ "Core300S": "fan", "Core400S": "fan", "Core600S": "fan", + "Vital200S": "fan", } FAN_MODE_AUTO = "auto" FAN_MODE_SLEEP = "sleep" +FAN_MODE_PET = "pet" PRESET_MODES = { "LV-PUR131S": [FAN_MODE_AUTO, FAN_MODE_SLEEP], @@ -38,6 +40,7 @@ "Core300S": [FAN_MODE_AUTO, FAN_MODE_SLEEP], "Core400S": [FAN_MODE_AUTO, FAN_MODE_SLEEP], "Core600S": [FAN_MODE_AUTO, FAN_MODE_SLEEP], + "Vital200S": [FAN_MODE_AUTO, FAN_MODE_SLEEP, FAN_MODE_PET], } SPEED_RANGE = { # off is not included "LV-PUR131S": (1, 3), @@ -45,6 +48,7 @@ "Core300S": (1, 3), "Core400S": (1, 4), "Core600S": (1, 4), + "Vital200S": (1, 4), } diff --git a/homeassistant/components/vicare/binary_sensor.py b/homeassistant/components/vicare/binary_sensor.py index 89e8bec42d105c..5aa76dc99629df 100644 --- a/homeassistant/components/vicare/binary_sensor.py +++ b/homeassistant/components/vicare/binary_sensor.py @@ -196,23 +196,18 @@ def __init__( self._api = api self.entity_description = description self._device_config = device_config - self._state = None - - @property - def device_info(self) -> DeviceInfo: - """Return device info for this device.""" - return DeviceInfo( - identifiers={(DOMAIN, self._device_config.getConfig().serial)}, - name=self._device_config.getModel(), + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device_config.getConfig().serial)}, + name=device_config.getModel(), manufacturer="Viessmann", - model=self._device_config.getModel(), + model=device_config.getModel(), configuration_url="https://developer.viessmann.com/", ) @property def available(self): """Return True if entity is available.""" - return self._state is not None + return self._attr_is_on is not None @property def unique_id(self) -> str: @@ -224,16 +219,11 @@ def unique_id(self) -> str: return f"{tmp_id}-{self._api.id}" return tmp_id - @property - def is_on(self): - """Return the state of the sensor.""" - return self._state - def update(self): """Update state of sensor.""" try: with suppress(PyViCareNotSupportedFeatureError): - self._state = self.entity_description.value_getter(self._api) + self._attr_is_on = self.entity_description.value_getter(self._api) except requests.exceptions.ConnectionError: _LOGGER.error("Unable to retrieve data from ViCare server") except ValueError: diff --git a/homeassistant/components/vicare/button.py b/homeassistant/components/vicare/button.py index ac025ff37d10fd..7fd8cccd3a45df 100644 --- a/homeassistant/components/vicare/button.py +++ b/homeassistant/components/vicare/button.py @@ -104,6 +104,13 @@ def __init__( self.entity_description = description self._device_config = device_config self._api = api + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device_config.getConfig().serial)}, + name=device_config.getModel(), + manufacturer="Viessmann", + model=device_config.getModel(), + configuration_url="https://developer.viessmann.com/", + ) def press(self) -> None: """Handle the button press.""" @@ -119,17 +126,6 @@ def press(self) -> None: except PyViCareInvalidDataError as invalid_data_exception: _LOGGER.error("Invalid data from Vicare server: %s", invalid_data_exception) - @property - def device_info(self) -> DeviceInfo: - """Return device info for this device.""" - return DeviceInfo( - identifiers={(DOMAIN, self._device_config.getConfig().serial)}, - name=self._device_config.getModel(), - manufacturer="Viessmann", - model=self._device_config.getModel(), - configuration_url="https://developer.viessmann.com/", - ) - @property def unique_id(self) -> str: """Return unique ID for this device.""" diff --git a/homeassistant/components/vicare/climate.py b/homeassistant/components/vicare/climate.py index d5beff4b268dda..a9188adc964f95 100644 --- a/homeassistant/components/vicare/climate.py +++ b/homeassistant/components/vicare/climate.py @@ -36,13 +36,7 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ( - CONF_HEATING_TYPE, - DOMAIN, - VICARE_API, - VICARE_DEVICE_CONFIG, - VICARE_NAME, -) +from .const import DOMAIN, VICARE_API, VICARE_DEVICE_CONFIG, VICARE_NAME _LOGGER = logging.getLogger(__name__) @@ -126,7 +120,6 @@ async def async_setup_entry( api, circuit, hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG], - config_entry.data[CONF_HEATING_TYPE], ) entities.append(entity) @@ -149,35 +142,26 @@ class ViCareClimate(ClimateEntity): ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE ) _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_min_temp = VICARE_TEMP_HEATING_MIN + _attr_max_temp = VICARE_TEMP_HEATING_MAX + _attr_target_temperature_step = PRECISION_WHOLE + _attr_preset_modes = list(HA_TO_VICARE_PRESET_HEATING) - def __init__(self, name, api, circuit, device_config, heating_type): + def __init__(self, name, api, circuit, device_config): """Initialize the climate device.""" - self._name = name - self._state = None + self._attr_name = name self._api = api self._circuit = circuit - self._device_config = device_config self._attributes = {} - self._target_temperature = None self._current_mode = None - self._current_temperature = None self._current_program = None - self._heating_type = heating_type self._current_action = None - - @property - def unique_id(self) -> str: - """Return unique ID for this device.""" - return f"{self._device_config.getConfig().serial}-{self._circuit.id}" - - @property - def device_info(self) -> DeviceInfo: - """Return device info for this device.""" - return DeviceInfo( - identifiers={(DOMAIN, self._device_config.getConfig().serial)}, - name=self._device_config.getModel(), + self._attr_unique_id = f"{device_config.getConfig().serial}-{circuit.id}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device_config.getConfig().serial)}, + name=device_config.getModel(), manufacturer="Viessmann", - model=self._device_config.getModel(), + model=device_config.getModel(), configuration_url="https://developer.viessmann.com/", ) @@ -193,27 +177,29 @@ def update(self) -> None: _supply_temperature = self._circuit.getSupplyTemperature() if _room_temperature is not None: - self._current_temperature = _room_temperature + self._attr_current_temperature = _room_temperature elif _supply_temperature is not None: - self._current_temperature = _supply_temperature + self._attr_current_temperature = _supply_temperature else: - self._current_temperature = None + self._attr_current_temperature = None with suppress(PyViCareNotSupportedFeatureError): self._current_program = self._circuit.getActiveProgram() with suppress(PyViCareNotSupportedFeatureError): - self._target_temperature = self._circuit.getCurrentDesiredTemperature() + self._attr_target_temperature = ( + self._circuit.getCurrentDesiredTemperature() + ) with suppress(PyViCareNotSupportedFeatureError): self._current_mode = self._circuit.getActiveMode() # Update the generic device attributes - self._attributes = {} - - self._attributes["room_temperature"] = _room_temperature - self._attributes["active_vicare_program"] = self._current_program - self._attributes["active_vicare_mode"] = self._current_mode + self._attributes = { + "room_temperature": _room_temperature, + "active_vicare_program": self._current_program, + "active_vicare_mode": self._current_mode, + } with suppress(PyViCareNotSupportedFeatureError): self._attributes[ @@ -248,21 +234,6 @@ def update(self) -> None: except PyViCareInvalidDataError as invalid_data_exception: _LOGGER.error("Invalid data from Vicare server: %s", invalid_data_exception) - @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 hvac_mode(self) -> HVACMode | None: """Return current hvac mode.""" @@ -313,37 +284,17 @@ def hvac_action(self) -> HVACAction: return HVACAction.HEATING return HVACAction.IDLE - @property - def min_temp(self): - """Return the minimum temperature.""" - return VICARE_TEMP_HEATING_MIN - - @property - def max_temp(self): - """Return the maximum temperature.""" - return VICARE_TEMP_HEATING_MAX - - @property - def target_temperature_step(self) -> float: - """Set target temperature step to wholes.""" - return PRECISION_WHOLE - def set_temperature(self, **kwargs: Any) -> None: """Set new target temperatures.""" if (temp := kwargs.get(ATTR_TEMPERATURE)) is not None: self._circuit.setProgramTemperature(self._current_program, temp) - self._target_temperature = temp + self._attr_target_temperature = temp @property def preset_mode(self): """Return the current preset mode, e.g., home, away, temp.""" return VICARE_TO_HA_PRESET_HEATING.get(self._current_program) - @property - def preset_modes(self): - """Return the available preset mode.""" - return list(HA_TO_VICARE_PRESET_HEATING) - def set_preset_mode(self, preset_mode: str) -> None: """Set new preset mode and deactivate any existing programs.""" vicare_program = HA_TO_VICARE_PRESET_HEATING.get(preset_mode) diff --git a/homeassistant/components/vicare/sensor.py b/homeassistant/components/vicare/sensor.py index 24f23b0da0ad5d..d7ac7f25274ee6 100644 --- a/homeassistant/components/vicare/sensor.py +++ b/homeassistant/components/vicare/sensor.py @@ -673,7 +673,6 @@ def __init__( self._attr_name = name self._api = api self._device_config = device_config - self._state = None @property def device_info(self) -> DeviceInfo: @@ -689,7 +688,7 @@ def device_info(self) -> DeviceInfo: @property def available(self): """Return True if entity is available.""" - return self._state is not None + return self._attr_native_value is not None @property def unique_id(self) -> str: @@ -701,16 +700,13 @@ def unique_id(self) -> str: return f"{tmp_id}-{self._api.id}" return tmp_id - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state - def update(self): """Update state of sensor.""" try: with suppress(PyViCareNotSupportedFeatureError): - self._state = self.entity_description.value_getter(self._api) + self._attr_native_value = self.entity_description.value_getter( + self._api + ) if self.entity_description.unit_getter: vicare_unit = self.entity_description.unit_getter(self._api) diff --git a/homeassistant/components/vicare/water_heater.py b/homeassistant/components/vicare/water_heater.py index c0d77dd46b68df..3357d2e0a317a2 100644 --- a/homeassistant/components/vicare/water_heater.py +++ b/homeassistant/components/vicare/water_heater.py @@ -15,23 +15,12 @@ WaterHeaterEntityFeature, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_TEMPERATURE, - PRECISION_TENTHS, - PRECISION_WHOLE, - UnitOfTemperature, -) +from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ( - CONF_HEATING_TYPE, - DOMAIN, - VICARE_API, - VICARE_DEVICE_CONFIG, - VICARE_NAME, -) +from .const import DOMAIN, VICARE_API, VICARE_DEVICE_CONFIG, VICARE_NAME _LOGGER = logging.getLogger(__name__) @@ -95,7 +84,6 @@ async def async_setup_entry( api, circuit, hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG], - config_entry.data[CONF_HEATING_TYPE], ) entities.append(entity) @@ -107,30 +95,37 @@ class ViCareWater(WaterHeaterEntity): _attr_precision = PRECISION_TENTHS _attr_supported_features = WaterHeaterEntityFeature.TARGET_TEMPERATURE + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_min_temp = VICARE_TEMP_WATER_MIN + _attr_max_temp = VICARE_TEMP_WATER_MAX + _attr_operation_list = list(HA_TO_VICARE_HVAC_DHW) - def __init__(self, name, api, circuit, device_config, heating_type): + def __init__(self, name, api, circuit, device_config): """Initialize the DHW water_heater device.""" - self._name = name - self._state = None + self._attr_name = name self._api = api self._circuit = circuit - self._device_config = device_config self._attributes = {} - self._target_temperature = None - self._current_temperature = None self._current_mode = None - self._heating_type = heating_type + self._attr_unique_id = f"{device_config.getConfig().serial}-{circuit.id}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device_config.getConfig().serial)}, + name=device_config.getModel(), + manufacturer="Viessmann", + model=device_config.getModel(), + configuration_url="https://developer.viessmann.com/", + ) def update(self) -> None: """Let HA know there has been an update from the ViCare API.""" try: with suppress(PyViCareNotSupportedFeatureError): - self._current_temperature = ( + self._attr_current_temperature = ( self._api.getDomesticHotWaterStorageTemperature() ) with suppress(PyViCareNotSupportedFeatureError): - self._target_temperature = ( + self._attr_target_temperature = ( self._api.getDomesticHotWaterDesiredTemperature() ) @@ -146,69 +141,13 @@ def update(self) -> None: except PyViCareInvalidDataError as invalid_data_exception: _LOGGER.error("Invalid data from Vicare server: %s", invalid_data_exception) - @property - def unique_id(self) -> str: - """Return unique ID for this device.""" - return f"{self._device_config.getConfig().serial}-{self._circuit.id}" - - @property - def device_info(self) -> DeviceInfo: - """Return device info for this device.""" - return DeviceInfo( - identifiers={(DOMAIN, self._device_config.getConfig().serial)}, - name=self._device_config.getModel(), - manufacturer="Viessmann", - model=self._device_config.getModel(), - configuration_url="https://developer.viessmann.com/", - ) - - @property - def name(self): - """Return the name of the water_heater device.""" - return self._name - - @property - def temperature_unit(self): - """Return the unit of measurement.""" - return UnitOfTemperature.CELSIUS - - @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 - def set_temperature(self, **kwargs: Any) -> None: """Set new target temperatures.""" if (temp := kwargs.get(ATTR_TEMPERATURE)) is not None: self._api.setDomesticHotWaterTemperature(temp) - self._target_temperature = temp - - @property - def min_temp(self): - """Return the minimum temperature.""" - return VICARE_TEMP_WATER_MIN - - @property - def max_temp(self): - """Return the maximum temperature.""" - return VICARE_TEMP_WATER_MAX - - @property - def target_temperature_step(self) -> float: - """Set target temperature step to wholes.""" - return PRECISION_WHOLE + self._attr_target_temperature = temp @property def current_operation(self): """Return current operation ie. heat, cool, idle.""" return VICARE_TO_HA_HVAC_DHW.get(self._current_mode) - - @property - def operation_list(self): - """Return the list of available operation modes.""" - return list(HA_TO_VICARE_HVAC_DHW) diff --git a/homeassistant/components/vizio/__init__.py b/homeassistant/components/vizio/__init__.py index d694f4b93f884b..0f5b3bc967cb7f 100644 --- a/homeassistant/components/vizio/__init__.py +++ b/homeassistant/components/vizio/__init__.py @@ -107,7 +107,6 @@ def __init__(self, hass: HomeAssistant, store: Store) -> None: _LOGGER, name=DOMAIN, update_interval=timedelta(days=1), - update_method=self._async_update_data, ) self.fail_count = 0 self.fail_threshold = 10 diff --git a/homeassistant/components/vlc_telnet/media_player.py b/homeassistant/components/vlc_telnet/media_player.py index 87bc158331ea8e..ef1df676a2dde2 100644 --- a/homeassistant/components/vlc_telnet/media_player.py +++ b/homeassistant/components/vlc_telnet/media_player.py @@ -2,7 +2,6 @@ from __future__ import annotations from collections.abc import Awaitable, Callable, Coroutine -from datetime import datetime from functools import wraps from typing import Any, Concatenate, ParamSpec, TypeVar @@ -59,9 +58,9 @@ async def wrapper(self: _VlcDeviceT, *args: _P.args, **kwargs: _P.kwargs) -> Non LOGGER.error("Command error: %s", err) except ConnectError as err: # pylint: disable=protected-access - if self._available: + if self._attr_available: LOGGER.error("Connection error: %s", err) - self._available = False + self._attr_available = False return wrapper @@ -86,22 +85,16 @@ class VlcDevice(MediaPlayerEntity): | MediaPlayerEntityFeature.VOLUME_SET | MediaPlayerEntityFeature.BROWSE_MEDIA ) + _volume_bkp = 0.0 + volume_level: int def __init__( self, config_entry: ConfigEntry, vlc: Client, name: str, available: bool ) -> None: """Initialize the vlc device.""" self._config_entry = config_entry - self._volume: float | None = None - self._muted: bool | None = None - self._media_position_updated_at: datetime | None = None - self._media_position: int | None = None - self._media_duration: int | None = None self._vlc = vlc - self._available = available - self._volume_bkp = 0.0 - self._media_artist: str | None = None - self._media_title: str | None = None + self._attr_available = available config_entry_id = config_entry.entry_id self._attr_unique_id = config_entry_id self._attr_device_info = DeviceInfo( @@ -115,7 +108,7 @@ def __init__( @catch_vlc_errors async def async_update(self) -> None: """Get the latest details from the device.""" - if not self._available: + if not self.available: try: await self._vlc.connect() except ConnectError as err: @@ -132,13 +125,13 @@ async def async_update(self) -> None: return self._attr_state = MediaPlayerState.IDLE - self._available = True + self._attr_available = True LOGGER.info("Connected to vlc host: %s", self._vlc.host) status = await self._vlc.status() LOGGER.debug("Status: %s", status) - self._volume = status.audio_volume / MAX_VOLUME + self._attr_volume_level = status.audio_volume / MAX_VOLUME state = status.state if state == "playing": self._attr_state = MediaPlayerState.PLAYING @@ -148,80 +141,42 @@ async def async_update(self) -> None: self._attr_state = MediaPlayerState.IDLE if self._attr_state != MediaPlayerState.IDLE: - self._media_duration = (await self._vlc.get_length()).length + self._attr_media_duration = (await self._vlc.get_length()).length time_output = await self._vlc.get_time() vlc_position = time_output.time # Check if current position is stale. - if vlc_position != self._media_position: - self._media_position_updated_at = dt_util.utcnow() - self._media_position = vlc_position + if vlc_position != self.media_position: + self._attr_media_position_updated_at = dt_util.utcnow() + self._attr_media_position = vlc_position info = await self._vlc.info() data = info.data LOGGER.debug("Info data: %s", data) self._attr_media_album_name = data.get("data", {}).get("album") - self._media_artist = data.get("data", {}).get("artist") - self._media_title = data.get("data", {}).get("title") + self._attr_media_artist = data.get("data", {}).get("artist") + self._attr_media_title = data.get("data", {}).get("title") now_playing = data.get("data", {}).get("now_playing") # Many radio streams put artist/title/album in now_playing and title is the station name. if now_playing: - if not self._media_artist: - self._media_artist = self._media_title - self._media_title = now_playing + if not self.media_artist: + self._attr_media_artist = self._attr_media_title + self._attr_media_title = now_playing - if self._media_title: + if self.media_title: return # Fall back to filename. if data_info := data.get("data"): - self._media_title = data_info["filename"] + self._attr_media_title = data_info["filename"] # Strip out auth signatures if streaming local media - if self._media_title and (pos := self._media_title.find("?authSig=")) != -1: - self._media_title = self._media_title[:pos] - - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self._available - - @property - def volume_level(self) -> float | None: - """Volume level of the media player (0..1).""" - return self._volume - - @property - def is_volume_muted(self) -> bool | None: - """Boolean if volume is currently muted.""" - return self._muted - - @property - def media_duration(self) -> int | None: - """Duration of current playing media in seconds.""" - return self._media_duration - - @property - def media_position(self) -> int | None: - """Position of current playing media in seconds.""" - return self._media_position - - @property - def media_position_updated_at(self) -> datetime | None: - """When was the position of the current playing media valid.""" - return self._media_position_updated_at - - @property - def media_title(self) -> str | None: - """Title of current playing media.""" - return self._media_title - - @property - def media_artist(self) -> str | None: - """Artist of current playing media, music track only.""" - return self._media_artist + if (media_title := self.media_title) and ( + pos := media_title.find("?authSig=") + ) != -1: + self._attr_media_title = media_title[:pos] @catch_vlc_errors async def async_media_seek(self, position: float) -> None: @@ -231,24 +186,24 @@ async def async_media_seek(self, position: float) -> None: @catch_vlc_errors async def async_mute_volume(self, mute: bool) -> None: """Mute the volume.""" - assert self._volume is not None + assert self._attr_volume_level is not None if mute: - self._volume_bkp = self._volume + self._volume_bkp = self._attr_volume_level await self.async_set_volume_level(0) else: await self.async_set_volume_level(self._volume_bkp) - self._muted = mute + self._attr_is_volume_muted = mute @catch_vlc_errors async def async_set_volume_level(self, volume: float) -> None: """Set volume level, range 0..1.""" await self._vlc.set_volume(round(volume * MAX_VOLUME)) - self._volume = volume + self._attr_volume_level = volume - if self._muted and self._volume > 0: + if self.is_volume_muted and self.volume_level > 0: # This can happen if we were muted and then see a volume_up. - self._muted = False + self._attr_is_volume_muted = False @catch_vlc_errors async def async_media_play(self) -> None: diff --git a/homeassistant/components/vodafone_station/__init__.py b/homeassistant/components/vodafone_station/__init__.py index c1cf23d974f72d..cf2a22d2dbc874 100644 --- a/homeassistant/components/vodafone_station/__init__.py +++ b/homeassistant/components/vodafone_station/__init__.py @@ -7,7 +7,7 @@ from .const import DOMAIN from .coordinator import VodafoneStationRouter -PLATFORMS = [Platform.DEVICE_TRACKER] +PLATFORMS = [Platform.DEVICE_TRACKER, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/vodafone_station/const.py b/homeassistant/components/vodafone_station/const.py index 8d5a60afb60616..c4828e19951356 100644 --- a/homeassistant/components/vodafone_station/const.py +++ b/homeassistant/components/vodafone_station/const.py @@ -8,4 +8,5 @@ DEFAULT_DEVICE_NAME = "Unknown device" DEFAULT_HOST = "192.168.1.1" DEFAULT_USERNAME = "vodafone" -DEFAULT_SSL = True + +LINE_TYPES = ["dsl", "fiber", "internet_key"] diff --git a/homeassistant/components/vodafone_station/coordinator.py b/homeassistant/components/vodafone_station/coordinator.py index b79acac9ce99b5..58079180bf8cca 100644 --- a/homeassistant/components/vodafone_station/coordinator.py +++ b/homeassistant/components/vodafone_station/coordinator.py @@ -112,9 +112,9 @@ async def _async_update_data(self) -> UpdateCoordinatorDataType: dev_info, utc_point_in_time ), ) - for dev_info in (await self.api.get_all_devices()).values() + for dev_info in (await self.api.get_devices_data()).values() } - data_sensors = await self.api.get_user_data() + data_sensors = await self.api.get_sensor_data() await self.api.logout() return UpdateCoordinatorDataType(data_devices, data_sensors) diff --git a/homeassistant/components/vodafone_station/manifest.json b/homeassistant/components/vodafone_station/manifest.json index 7069629ca2e206..68e7665b5ac538 100644 --- a/homeassistant/components/vodafone_station/manifest.json +++ b/homeassistant/components/vodafone_station/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/vodafone_station", "iot_class": "local_polling", "loggers": ["aiovodafone"], - "requirements": ["aiovodafone==0.0.6"] + "requirements": ["aiovodafone==0.2.0"] } diff --git a/homeassistant/components/vodafone_station/sensor.py b/homeassistant/components/vodafone_station/sensor.py new file mode 100644 index 00000000000000..0ca705ad56bdec --- /dev/null +++ b/homeassistant/components/vodafone_station/sensor.py @@ -0,0 +1,217 @@ +"""Vodafone Station sensors.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime, timedelta +from typing import Any, Final + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfDataRate +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util.dt import utcnow + +from .const import _LOGGER, DOMAIN, LINE_TYPES +from .coordinator import VodafoneStationRouter + +NOT_AVAILABLE: list = ["", "N/A", "0.0.0.0"] + + +@dataclass +class VodafoneStationBaseEntityDescription: + """Vodafone Station entity base description.""" + + value: Callable[[Any, Any], Any] = lambda val, key: val[key] + is_suitable: Callable[[dict], bool] = lambda val: True + + +@dataclass +class VodafoneStationEntityDescription( + VodafoneStationBaseEntityDescription, SensorEntityDescription +): + """Vodafone Station entity description.""" + + +def _calculate_uptime(value: dict, key: str) -> datetime: + """Calculate device uptime.""" + d = int(value[key].split(":")[0]) + h = int(value[key].split(":")[1]) + m = int(value[key].split(":")[2]) + + return utcnow() - timedelta(days=d, hours=h, minutes=m) + + +def _line_connection(value: dict, key: str) -> str | None: + """Identify line type.""" + + internet_ip = value[key] + dsl_ip = value.get("dsl_ipaddr") + fiber_ip = value.get("fiber_ipaddr") + internet_key_ip = value.get("vf_internet_key_ip_addr") + + if internet_ip == dsl_ip: + return LINE_TYPES[0] + + if internet_ip == fiber_ip: + return LINE_TYPES[1] + + if internet_ip == internet_key_ip: + return LINE_TYPES[2] + + return None + + +SENSOR_TYPES: Final = ( + VodafoneStationEntityDescription( + key="wan_ip4_addr", + translation_key="external_ipv4", + icon="mdi:earth", + is_suitable=lambda info: info["wan_ip4_addr"] not in NOT_AVAILABLE, + ), + VodafoneStationEntityDescription( + key="wan_ip6_addr", + translation_key="external_ipv6", + icon="mdi:earth", + is_suitable=lambda info: info["wan_ip6_addr"] not in NOT_AVAILABLE, + ), + VodafoneStationEntityDescription( + key="vf_internet_key_ip_addr", + translation_key="external_ip_key", + icon="mdi:earth", + is_suitable=lambda info: info["vf_internet_key_ip_addr"] not in NOT_AVAILABLE, + ), + VodafoneStationEntityDescription( + key="inter_ip_address", + translation_key="active_connection", + device_class=SensorDeviceClass.ENUM, + icon="mdi:wan", + options=LINE_TYPES, + value=_line_connection, + ), + VodafoneStationEntityDescription( + key="down_str", + translation_key="down_stream", + device_class=SensorDeviceClass.DATA_RATE, + native_unit_of_measurement=UnitOfDataRate.KILOBYTES_PER_SECOND, + entity_category=EntityCategory.DIAGNOSTIC, + ), + VodafoneStationEntityDescription( + key="up_str", + translation_key="up_stream", + device_class=SensorDeviceClass.DATA_RATE, + native_unit_of_measurement=UnitOfDataRate.KILOBYTES_PER_SECOND, + entity_category=EntityCategory.DIAGNOSTIC, + ), + VodafoneStationEntityDescription( + key="fw_version", + translation_key="fw_version", + icon="mdi:new-box", + entity_category=EntityCategory.DIAGNOSTIC, + ), + VodafoneStationEntityDescription( + key="phone_num1", + translation_key="phone_num1", + icon="mdi:phone", + is_suitable=lambda info: info["phone_unavailable1"] == "0", + ), + VodafoneStationEntityDescription( + key="phone_num2", + translation_key="phone_num2", + icon="mdi:phone", + is_suitable=lambda info: info["phone_unavailable2"] == "0", + ), + VodafoneStationEntityDescription( + key="sys_uptime", + translation_key="sys_uptime", + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + value=_calculate_uptime, + ), + VodafoneStationEntityDescription( + key="sys_cpu_usage", + translation_key="sys_cpu_usage", + icon="mdi:chip", + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + value=lambda value, key: float(value[key][:-1]), + ), + VodafoneStationEntityDescription( + key="sys_memory_usage", + translation_key="sys_memory_usage", + icon="mdi:memory", + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + value=lambda value, key: float(value[key][:-1]), + ), + VodafoneStationEntityDescription( + key="sys_reboot_cause", + translation_key="sys_reboot_cause", + icon="mdi:restart-alert", + entity_category=EntityCategory.DIAGNOSTIC, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up entry.""" + _LOGGER.debug("Setting up Vodafone Station sensors") + + coordinator: VodafoneStationRouter = hass.data[DOMAIN][entry.entry_id] + + sensors_data = coordinator.data.sensors + + async_add_entities( + VodafoneStationSensorEntity(coordinator, sensor_descr) + for sensor_descr in SENSOR_TYPES + if sensor_descr.key in sensors_data and sensor_descr.is_suitable(sensors_data) + ) + + +class VodafoneStationSensorEntity( + CoordinatorEntity[VodafoneStationRouter], SensorEntity +): + """Representation of a Vodafone Station sensor.""" + + _attr_has_entity_name = True + entity_description: VodafoneStationEntityDescription + + def __init__( + self, + coordinator: VodafoneStationRouter, + description: VodafoneStationEntityDescription, + ) -> None: + """Initialize a Vodafone Station sensor.""" + super().__init__(coordinator) + + sensors_data = coordinator.data.sensors + serial_num = sensors_data["sys_serial_number"] + self.entity_description = description + + self._attr_device_info = DeviceInfo( + configuration_url=coordinator.api.base_url, + identifiers={(DOMAIN, serial_num)}, + name=f"Vodafone Station ({serial_num})", + manufacturer="Vodafone", + model=sensors_data.get("sys_model_name"), + hw_version=sensors_data["sys_hardware_version"], + sw_version=sensors_data["sys_firmware_version"], + ) + self._attr_unique_id = f"{serial_num}_{description.key}" + + @property + def native_value(self) -> StateType: + """Sensor value.""" + return self.entity_description.value( + self.coordinator.data.sensors, self.entity_description.key + ) diff --git a/homeassistant/components/vodafone_station/strings.json b/homeassistant/components/vodafone_station/strings.json index 3c452133c287aa..0c2a4a408dda8b 100644 --- a/homeassistant/components/vodafone_station/strings.json +++ b/homeassistant/components/vodafone_station/strings.json @@ -29,5 +29,30 @@ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "unknown": "[%key:common::config_flow::error::unknown%]" } + }, + "entity": { + "sensor": { + "external_ipv4": { "name": "WAN IPv4 address" }, + "external_ipv6": { "name": "WAN IPv6 address" }, + "external_ip_key": { "name": "WAN internet key address" }, + "active_connection": { + "name": "Active connection", + "state": { + "unknown": "Unknown", + "dsl": "xDSL", + "fiber": "Fiber", + "internet_key": "Internet key" + } + }, + "down_stream": { "name": "WAN download rate" }, + "up_stream": { "name": "WAN upload rate" }, + "fw_version": { "name": "Firmware version" }, + "phone_num1": { "name": "Phone number (1)" }, + "phone_num2": { "name": "Phone number (2)" }, + "sys_uptime": { "name": "Uptime" }, + "sys_cpu_usage": { "name": "CPU usage" }, + "sys_memory_usage": { "name": "Memory usage" }, + "sys_reboot_cause": { "name": "Reboot cause" } + } } } diff --git a/homeassistant/components/volumio/media_player.py b/homeassistant/components/volumio/media_player.py index d207e36e3c964f..a11ea62e355ce4 100644 --- a/homeassistant/components/volumio/media_player.py +++ b/homeassistant/components/volumio/media_player.py @@ -69,39 +69,28 @@ class Volumio(MediaPlayerEntity): | MediaPlayerEntityFeature.CLEAR_PLAYLIST | MediaPlayerEntityFeature.BROWSE_MEDIA ) + _attr_source_list = [] def __init__(self, volumio, uid, name, info): """Initialize the media player.""" self._volumio = volumio - self._uid = uid - self._name = name - self._info = info + unique_id = uid self._state = {} - self._playlists = [] - self._currentplaylist = None self.thumbnail_cache = {} + self._attr_unique_id = unique_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + manufacturer="Volumio", + model=info["hardware"], + name=name, + sw_version=info["systemversion"], + ) async def async_update(self) -> None: """Update state.""" self._state = await self._volumio.get_state() await self._async_update_playlists() - @property - def unique_id(self): - """Return the unique id for the entity.""" - return self._uid - - @property - def device_info(self) -> DeviceInfo: - """Return device info for this device.""" - return DeviceInfo( - identifiers={(DOMAIN, self.unique_id)}, - manufacturer="Volumio", - model=self._info["hardware"], - name=self._name, - sw_version=self._info["systemversion"], - ) - @property def state(self) -> MediaPlayerState: """Return the state of the device.""" @@ -169,16 +158,6 @@ def repeat(self) -> RepeatMode: return RepeatMode.ALL return RepeatMode.OFF - @property - def source_list(self): - """Return the list of available input sources.""" - return self._playlists - - @property - def source(self): - """Name of the current input source.""" - return self._currentplaylist - async def async_media_next_track(self) -> None: """Send media_next command to media player.""" await self._volumio.next() @@ -235,17 +214,17 @@ async def async_set_repeat(self, repeat: RepeatMode) -> None: async def async_select_source(self, source: str) -> None: """Choose an available playlist and play it.""" await self._volumio.play_playlist(source) - self._currentplaylist = source + self._attr_source = source async def async_clear_playlist(self) -> None: """Clear players playlist.""" await self._volumio.clear_playlist() - self._currentplaylist = None + self._attr_source = None @Throttle(PLAYLIST_UPDATE_INTERVAL) async def _async_update_playlists(self, **kwargs): """Update available Volumio playlists.""" - self._playlists = await self._volumio.get_playlists() + self._attr_source_list = await self._volumio.get_playlists() async def async_play_media( self, media_type: MediaType | str, media_id: str, **kwargs: Any diff --git a/homeassistant/components/vulcan/strings.json b/homeassistant/components/vulcan/strings.json index b2b270e3422fc7..07a0510f48231b 100644 --- a/homeassistant/components/vulcan/strings.json +++ b/homeassistant/components/vulcan/strings.json @@ -7,13 +7,13 @@ "no_matching_entries": "No matching entries found, please use different account or remove integration with outdated student.." }, "error": { - "unknown": "Unknown error occurred", - "invalid_token": "Invalid token", + "unknown": "[%key:common::config_flow::error::unknown%]", + "invalid_token": "[%key:common::config_flow::error::invalid_access_token%]", "expired_token": "Expired token - please generate a new token", "invalid_pin": "Invalid pin", "invalid_symbol": "Invalid symbol", "expired_credentials": "Expired credentials - please create new on Vulcan mobile app registration page", - "cannot_connect": "Connection error - please check your internet connection" + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" }, "step": { "auth": { @@ -21,7 +21,7 @@ "data": { "token": "Token", "region": "Symbol", - "pin": "Pin" + "pin": "[%key:common::config_flow::data::pin%]" } }, "reauth_confirm": { diff --git a/homeassistant/components/waqi/__init__.py b/homeassistant/components/waqi/__init__.py index 5cacd9e5e1be2c..bc51a91364ce26 100644 --- a/homeassistant/components/waqi/__init__.py +++ b/homeassistant/components/waqi/__init__.py @@ -1 +1,37 @@ -"""The waqi component.""" +"""The World Air Quality Index (WAQI) integration.""" +from __future__ import annotations + +from aiowaqi import WAQIClient + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN +from .coordinator import WAQIDataUpdateCoordinator + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up World Air Quality Index (WAQI) from a config entry.""" + + client = WAQIClient(session=async_get_clientsession(hass)) + client.authenticate(entry.data[CONF_API_KEY]) + + waqi_coordinator = WAQIDataUpdateCoordinator(hass, client) + await waqi_coordinator.async_config_entry_first_refresh() + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = waqi_coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/waqi/config_flow.py b/homeassistant/components/waqi/config_flow.py new file mode 100644 index 00000000000000..b5f3a18b223e06 --- /dev/null +++ b/homeassistant/components/waqi/config_flow.py @@ -0,0 +1,135 @@ +"""Config flow for World Air Quality Index (WAQI) integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from aiowaqi import ( + WAQIAirQuality, + WAQIAuthenticationError, + WAQIClient, + WAQIConnectionError, +) +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import ( + CONF_API_KEY, + CONF_LATITUDE, + CONF_LOCATION, + CONF_LONGITUDE, + CONF_NAME, +) +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN +from homeassistant.data_entry_flow import AbortFlow, FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue +from homeassistant.helpers.selector import LocationSelector +from homeassistant.helpers.typing import ConfigType + +from .const import CONF_STATION_NUMBER, DOMAIN, ISSUE_PLACEHOLDER + +_LOGGER = logging.getLogger(__name__) + + +class WAQIConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for World Air Quality Index (WAQI).""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + async with WAQIClient( + session=async_get_clientsession(self.hass) + ) as waqi_client: + waqi_client.authenticate(user_input[CONF_API_KEY]) + location = user_input[CONF_LOCATION] + try: + measuring_station: WAQIAirQuality = ( + await waqi_client.get_by_coordinates( + location[CONF_LATITUDE], location[CONF_LONGITUDE] + ) + ) + except WAQIAuthenticationError: + errors["base"] = "invalid_auth" + except WAQIConnectionError: + errors["base"] = "cannot_connect" + except Exception as exc: # pylint: disable=broad-except + _LOGGER.exception(exc) + errors["base"] = "unknown" + else: + await self.async_set_unique_id(str(measuring_station.station_id)) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=measuring_station.city.name, + data={ + CONF_API_KEY: user_input[CONF_API_KEY], + CONF_STATION_NUMBER: measuring_station.station_id, + }, + ) + + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Required(CONF_API_KEY): str, + vol.Required( + CONF_LOCATION, + ): LocationSelector(), + } + ), + user_input + or { + CONF_LOCATION: { + CONF_LATITUDE: self.hass.config.latitude, + CONF_LONGITUDE: self.hass.config.longitude, + } + }, + ), + errors=errors, + ) + + async def async_step_import(self, import_config: ConfigType) -> FlowResult: + """Handle importing from yaml.""" + await self.async_set_unique_id(str(import_config[CONF_STATION_NUMBER])) + try: + self._abort_if_unique_id_configured() + except AbortFlow as exc: + async_create_issue( + self.hass, + DOMAIN, + "deprecated_yaml_import_issue_already_configured", + breaks_in_ha_version="2024.4.0", + is_fixable=False, + severity=IssueSeverity.ERROR, + translation_key="deprecated_yaml_import_issue_already_configured", + translation_placeholders=ISSUE_PLACEHOLDER, + ) + raise exc + + async_create_issue( + self.hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2024.4.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "World Air Quality Index", + }, + ) + return self.async_create_entry( + title=import_config[CONF_NAME], + data={ + CONF_API_KEY: import_config[CONF_API_KEY], + CONF_STATION_NUMBER: import_config[CONF_STATION_NUMBER], + }, + ) diff --git a/homeassistant/components/waqi/const.py b/homeassistant/components/waqi/const.py new file mode 100644 index 00000000000000..2847a29b8add8d --- /dev/null +++ b/homeassistant/components/waqi/const.py @@ -0,0 +1,10 @@ +"""Constants for the World Air Quality Index (WAQI) integration.""" +import logging + +DOMAIN = "waqi" + +LOGGER = logging.getLogger(__package__) + +CONF_STATION_NUMBER = "station_number" + +ISSUE_PLACEHOLDER = {"url": "/config/integrations/dashboard/add?domain=waqi"} diff --git a/homeassistant/components/waqi/coordinator.py b/homeassistant/components/waqi/coordinator.py new file mode 100644 index 00000000000000..b7beef8fda9038 --- /dev/null +++ b/homeassistant/components/waqi/coordinator.py @@ -0,0 +1,36 @@ +"""Coordinator for the World Air Quality Index (WAQI) integration.""" +from __future__ import annotations + +from datetime import timedelta + +from aiowaqi import WAQIAirQuality, WAQIClient, WAQIError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import CONF_STATION_NUMBER, DOMAIN, LOGGER + + +class WAQIDataUpdateCoordinator(DataUpdateCoordinator[WAQIAirQuality]): + """The WAQI Data Update Coordinator.""" + + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant, client: WAQIClient) -> None: + """Initialize the WAQI data coordinator.""" + super().__init__( + hass, + LOGGER, + name=DOMAIN, + update_interval=timedelta(minutes=5), + ) + self._client = client + + async def _async_update_data(self) -> WAQIAirQuality: + try: + return await self._client.get_by_station_number( + self.config_entry.data[CONF_STATION_NUMBER] + ) + except WAQIError as exc: + raise UpdateFailed from exc diff --git a/homeassistant/components/waqi/manifest.json b/homeassistant/components/waqi/manifest.json index 2022558a5006b2..bf31fb570a8d59 100644 --- a/homeassistant/components/waqi/manifest.json +++ b/homeassistant/components/waqi/manifest.json @@ -1,7 +1,8 @@ { "domain": "waqi", "name": "World Air Quality Index (WAQI)", - "codeowners": ["@andrey-git"], + "codeowners": ["@joostlek"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/waqi", "iot_class": "cloud_polling", "loggers": ["waqiasync"], diff --git a/homeassistant/components/waqi/sensor.py b/homeassistant/components/waqi/sensor.py index 51b9acb8e59a85..0ad295ca5af9a3 100644 --- a/homeassistant/components/waqi/sensor.py +++ b/homeassistant/components/waqi/sensor.py @@ -1,10 +1,9 @@ """Support for the World Air Quality Index service.""" from __future__ import annotations -from datetime import timedelta import logging -from aiowaqi import WAQIAirQuality, WAQIClient, WAQIConnectionError, WAQISearchResult +from aiowaqi import WAQIAuthenticationError, WAQIClient, WAQIConnectionError import voluptuous as vol from homeassistant.components.sensor import ( @@ -12,10 +11,13 @@ SensorEntity, SensorStateClass, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( ATTR_ATTRIBUTION, ATTR_TEMPERATURE, ATTR_TIME, + CONF_API_KEY, + CONF_NAME, CONF_TOKEN, ) from homeassistant.core import HomeAssistant @@ -23,7 +25,12 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import CONF_STATION_NUMBER, DOMAIN, ISSUE_PLACEHOLDER +from .coordinator import WAQIDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -43,8 +50,6 @@ CONF_LOCATIONS = "locations" CONF_STATIONS = "stations" -SCAN_INTERVAL = timedelta(minutes=5) - TIMEOUT = 10 PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend( @@ -70,102 +75,126 @@ async def async_setup_platform( client = WAQIClient(session=async_get_clientsession(hass), request_timeout=TIMEOUT) client.authenticate(token) - dev = [] + station_count = 0 try: for location_name in locations: stations = await client.search(location_name) _LOGGER.debug("The following stations were returned: %s", stations) for station in stations: - waqi_sensor = WaqiSensor(client, station) + station_count = station_count + 1 if not station_filter or { - waqi_sensor.uid, - waqi_sensor.url, - waqi_sensor.station_name, + station.station_id, + station.station.external_url, + station.station.name, } & set(station_filter): - dev.append(waqi_sensor) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_STATION_NUMBER: station.station_id, + CONF_NAME: station.station.name, + CONF_API_KEY: config[CONF_TOKEN], + }, + ) + ) + except WAQIAuthenticationError as err: + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml_import_issue_invalid_auth", + breaks_in_ha_version="2024.4.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml_import_issue_invalid_auth", + translation_placeholders=ISSUE_PLACEHOLDER, + ) + _LOGGER.exception("Could not authenticate with WAQI") + raise PlatformNotReady from err except WAQIConnectionError as err: + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml_import_issue_cannot_connect", + breaks_in_ha_version="2024.4.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml_import_issue_cannot_connect", + translation_placeholders=ISSUE_PLACEHOLDER, + ) _LOGGER.exception("Failed to connect to WAQI servers") raise PlatformNotReady from err - async_add_entities(dev, True) + if station_count == 0: + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml_import_issue_none_found", + breaks_in_ha_version="2024.4.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml_import_issue_none_found", + translation_placeholders=ISSUE_PLACEHOLDER, + ) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the WAQI sensor.""" + coordinator: WAQIDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities([WaqiSensor(coordinator)]) -class WaqiSensor(SensorEntity): +class WaqiSensor(CoordinatorEntity[WAQIDataUpdateCoordinator], SensorEntity): """Implementation of a WAQI sensor.""" _attr_icon = ATTR_ICON _attr_device_class = SensorDeviceClass.AQI _attr_state_class = SensorStateClass.MEASUREMENT - _data: WAQIAirQuality | None = None - - def __init__(self, client: WAQIClient, search_result: WAQISearchResult) -> None: + def __init__(self, coordinator: WAQIDataUpdateCoordinator) -> None: """Initialize the sensor.""" - self._client = client - self.uid = search_result.station_id - self.url = search_result.station.external_url - self.station_name = search_result.station.name - - @property - def name(self): - """Return the name of the sensor.""" - if self.station_name: - return f"WAQI {self.station_name}" - return f"WAQI {self.url if self.url else self.uid}" + super().__init__(coordinator) + self._attr_name = f"WAQI {self.coordinator.data.city.name}" + self._attr_unique_id = str(coordinator.data.station_id) @property def native_value(self) -> int | None: """Return the state of the device.""" - assert self._data - return self._data.air_quality_index - - @property - def available(self): - """Return sensor availability.""" - return self._data is not None - - @property - def unique_id(self): - """Return unique ID.""" - return self.uid + return self.coordinator.data.air_quality_index @property def extra_state_attributes(self): """Return the state attributes of the last update.""" attrs = {} - - if self._data is not None: - try: - attrs[ATTR_ATTRIBUTION] = " and ".join( - [ATTRIBUTION] - + [attribution.name for attribution in self._data.attributions] - ) - - attrs[ATTR_TIME] = self._data.measured_at - attrs[ATTR_DOMINENTPOL] = self._data.dominant_pollutant - - iaqi = self._data.extended_air_quality - - attribute = { - ATTR_PM2_5: iaqi.pm25, - ATTR_PM10: iaqi.pm10, - ATTR_HUMIDITY: iaqi.humidity, - ATTR_PRESSURE: iaqi.pressure, - ATTR_TEMPERATURE: iaqi.temperature, - ATTR_OZONE: iaqi.ozone, - ATTR_NITROGEN_DIOXIDE: iaqi.nitrogen_dioxide, - ATTR_SULFUR_DIOXIDE: iaqi.sulfur_dioxide, - } - res_attributes = {k: v for k, v in attribute.items() if v is not None} - return {**attrs, **res_attributes} - except (IndexError, KeyError): - return {ATTR_ATTRIBUTION: ATTRIBUTION} - - async def async_update(self) -> None: - """Get the latest data and updates the states.""" - if self.uid: - result = await self._client.get_by_station_number(self.uid) - elif self.url: - result = await self._client.get_by_name(self.url) - else: - result = None - self._data = result + try: + attrs[ATTR_ATTRIBUTION] = " and ".join( + [ATTRIBUTION] + + [ + attribution.name + for attribution in self.coordinator.data.attributions + ] + ) + + attrs[ATTR_TIME] = self.coordinator.data.measured_at + attrs[ATTR_DOMINENTPOL] = self.coordinator.data.dominant_pollutant + + iaqi = self.coordinator.data.extended_air_quality + + attribute = { + ATTR_PM2_5: iaqi.pm25, + ATTR_PM10: iaqi.pm10, + ATTR_HUMIDITY: iaqi.humidity, + ATTR_PRESSURE: iaqi.pressure, + ATTR_TEMPERATURE: iaqi.temperature, + ATTR_OZONE: iaqi.ozone, + ATTR_NITROGEN_DIOXIDE: iaqi.nitrogen_dioxide, + ATTR_SULFUR_DIOXIDE: iaqi.sulfur_dioxide, + } + res_attributes = {k: v for k, v in attribute.items() if v is not None} + return {**attrs, **res_attributes} + except (IndexError, KeyError): + return {ATTR_ATTRIBUTION: ATTRIBUTION} diff --git a/homeassistant/components/waqi/strings.json b/homeassistant/components/waqi/strings.json new file mode 100644 index 00000000000000..4ceb911de9e94a --- /dev/null +++ b/homeassistant/components/waqi/strings.json @@ -0,0 +1,39 @@ +{ + "config": { + "step": { + "user": { + "description": "Select a location to get the closest measuring station.", + "data": { + "location": "[%key:common::config_flow::data::location%]", + "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_device%]" + } + }, + "issues": { + "deprecated_yaml_import_issue_invalid_auth": { + "title": "The World Air Quality Index YAML configuration import failed", + "description": "Configuring World Air Quality Index using YAML is being removed but there was an authentication error importing your YAML configuration.\n\nCorrect the YAML configuration and restart Home Assistant to try again or remove the World Air Quality Index YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." + }, + "deprecated_yaml_import_issue_cannot_connect": { + "title": "The WAQI YAML configuration import failed", + "description": "Configuring World Air Quality Index using YAML is being removed but there was an connection error importing your YAML configuration.\n\nEnsure connection to WAQI works and restart Home Assistant to try again or remove the World Air Quality Index YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." + }, + "deprecated_yaml_import_issue_already_configured": { + "title": "The WAQI YAML configuration import failed", + "description": "Configuring World Air Quality Index using YAML is being removed but the measuring station was already imported when trying to import the YAML configuration.\n\nEnsure the imported configuration is correct and remove the World Air Quality Index YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." + }, + "deprecated_yaml_import_issue_none_found": { + "title": "The WAQI YAML configuration import failed", + "description": "Configuring World Air Quality Index using YAML is being removed but there weren't any stations imported because they couldn't be found.\n\nEnsure the imported configuration is correct and remove the World Air Quality Index YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." + } + } +} diff --git a/homeassistant/components/water_heater/__init__.py b/homeassistant/components/water_heater/__init__.py index b31d1306c55943..9e796092f6a04a 100644 --- a/homeassistant/components/water_heater/__init__.py +++ b/homeassistant/components/water_heater/__init__.py @@ -164,6 +164,10 @@ class WaterHeaterEntityEntityDescription(EntityDescription): class WaterHeaterEntity(Entity): """Base class for water heater entities.""" + _entity_component_unrecorded_attributes = frozenset( + {ATTR_OPERATION_LIST, ATTR_MIN_TEMP, ATTR_MAX_TEMP} + ) + entity_description: WaterHeaterEntityEntityDescription _attr_current_operation: str | None = None _attr_current_temperature: float | None = None diff --git a/homeassistant/components/water_heater/recorder.py b/homeassistant/components/water_heater/recorder.py deleted file mode 100644 index d76b96936fafa1..00000000000000 --- a/homeassistant/components/water_heater/recorder.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.core import HomeAssistant, callback - -from . import ATTR_MAX_TEMP, ATTR_MIN_TEMP, ATTR_OPERATION_LIST - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude static attributes from being recorded in the database.""" - return {ATTR_OPERATION_LIST, ATTR_MIN_TEMP, ATTR_MAX_TEMP} diff --git a/homeassistant/components/waze_travel_time/manifest.json b/homeassistant/components/waze_travel_time/manifest.json index 3f1f8c6d67ba22..1a4be79836747d 100644 --- a/homeassistant/components/waze_travel_time/manifest.json +++ b/homeassistant/components/waze_travel_time/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/waze_travel_time", "iot_class": "cloud_polling", "loggers": ["pywaze", "homeassistant.helpers.location"], - "requirements": ["pywaze==0.3.0"] + "requirements": ["pywaze==0.5.0"] } diff --git a/homeassistant/components/waze_travel_time/sensor.py b/homeassistant/components/waze_travel_time/sensor.py index 2b3010a39cb0c7..bf3544de8a9037 100644 --- a/homeassistant/components/waze_travel_time/sensor.py +++ b/homeassistant/components/waze_travel_time/sensor.py @@ -1,6 +1,7 @@ """Support for Waze travel time sensor.""" from __future__ import annotations +import asyncio from datetime import timedelta import logging from typing import Any @@ -48,6 +49,10 @@ SCAN_INTERVAL = timedelta(minutes=5) +PARALLEL_UPDATES = 1 + +MS_BETWEEN_API_CALLS = 0.5 + async def async_setup_entry( hass: HomeAssistant, @@ -144,6 +149,7 @@ async def async_update(self) -> None: self._waze_data.origin = find_coordinates(self.hass, self._origin) self._waze_data.destination = find_coordinates(self.hass, self._destination) await self._waze_data.async_update() + await asyncio.sleep(MS_BETWEEN_API_CALLS) class WazeTravelTimeData: diff --git a/homeassistant/components/weather/__init__.py b/homeassistant/components/weather/__init__.py index 0d72dbb825e9df..4ec9ea91f89b84 100644 --- a/homeassistant/components/weather/__init__.py +++ b/homeassistant/components/weather/__init__.py @@ -264,6 +264,8 @@ def __post_init__(self, *args: Any, **kwargs: Any) -> None: class WeatherEntity(Entity, PostInit): """ABC for weather data.""" + _entity_component_unrecorded_attributes = frozenset({ATTR_FORECAST}) + entity_description: WeatherEntityDescription _attr_condition: str | None = None # _attr_forecast is deprecated, implement async_forecast_daily, diff --git a/homeassistant/components/weather/recorder.py b/homeassistant/components/weather/recorder.py deleted file mode 100644 index 1c887ea52020b9..00000000000000 --- a/homeassistant/components/weather/recorder.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.core import HomeAssistant, callback - -from . import ATTR_FORECAST - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude (often large) forecasts from being recorded in the database.""" - return {ATTR_FORECAST} diff --git a/homeassistant/components/weatherkit/__init__.py b/homeassistant/components/weatherkit/__init__.py new file mode 100644 index 00000000000000..fb41ffc108440a --- /dev/null +++ b/homeassistant/components/weatherkit/__init__.py @@ -0,0 +1,62 @@ +"""Integration for Apple's WeatherKit API.""" +from __future__ import annotations + +from apple_weatherkit.client import ( + WeatherKitApiClient, + WeatherKitApiClientAuthenticationError, + WeatherKitApiClientError, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import ( + CONF_KEY_ID, + CONF_KEY_PEM, + CONF_SERVICE_ID, + CONF_TEAM_ID, + DOMAIN, + LOGGER, +) +from .coordinator import WeatherKitDataUpdateCoordinator + +PLATFORMS: list[Platform] = [Platform.WEATHER] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up this integration using UI.""" + hass.data.setdefault(DOMAIN, {}) + coordinator = WeatherKitDataUpdateCoordinator( + hass=hass, + client=WeatherKitApiClient( + key_id=entry.data[CONF_KEY_ID], + service_id=entry.data[CONF_SERVICE_ID], + team_id=entry.data[CONF_TEAM_ID], + key_pem=entry.data[CONF_KEY_PEM], + session=async_get_clientsession(hass), + ), + ) + + try: + await coordinator.update_supported_data_sets() + except WeatherKitApiClientAuthenticationError as ex: + LOGGER.error("Authentication error initializing integration: %s", ex) + return False + except WeatherKitApiClientError as ex: + raise ConfigEntryNotReady from ex + + await coordinator.async_config_entry_first_refresh() + hass.data[DOMAIN][entry.entry_id] = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Handle removal of an entry.""" + if unloaded := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + return unloaded diff --git a/homeassistant/components/weatherkit/config_flow.py b/homeassistant/components/weatherkit/config_flow.py new file mode 100644 index 00000000000000..5762c4ae9b2dce --- /dev/null +++ b/homeassistant/components/weatherkit/config_flow.py @@ -0,0 +1,126 @@ +"""Adds config flow for WeatherKit.""" +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from apple_weatherkit.client import ( + WeatherKitApiClient, + WeatherKitApiClientAuthenticationError, + WeatherKitApiClientCommunicationError, + WeatherKitApiClientError, +) +import voluptuous as vol + +from homeassistant import config_entries, data_entry_flow +from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import ( + LocationSelector, + LocationSelectorConfig, + TextSelector, + TextSelectorConfig, +) + +from .const import ( + CONF_KEY_ID, + CONF_KEY_PEM, + CONF_SERVICE_ID, + CONF_TEAM_ID, + DOMAIN, + LOGGER, +) + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_LOCATION): LocationSelector( + LocationSelectorConfig(radius=False, icon="") + ), + # Auth + vol.Required(CONF_KEY_ID): str, + vol.Required(CONF_SERVICE_ID): str, + vol.Required(CONF_TEAM_ID): str, + vol.Required(CONF_KEY_PEM): TextSelector( + TextSelectorConfig( + multiline=True, + ) + ), + } +) + + +class WeatherKitUnsupportedLocationError(Exception): + """Error to indicate a location is unsupported.""" + + +class WeatherKitFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Config flow for WeatherKit.""" + + VERSION = 1 + + async def async_step_user( + self, + user_input: dict[str, Any] | None = None, + ) -> data_entry_flow.FlowResult: + """Handle a flow initialized by the user.""" + errors = {} + if user_input is not None: + try: + await self._test_config(user_input) + except WeatherKitUnsupportedLocationError as exception: + LOGGER.error(exception) + errors["base"] = "unsupported_location" + except WeatherKitApiClientAuthenticationError as exception: + LOGGER.warning(exception) + errors["base"] = "invalid_auth" + except WeatherKitApiClientCommunicationError as exception: + LOGGER.error(exception) + errors["base"] = "cannot_connect" + except WeatherKitApiClientError as exception: + LOGGER.exception(exception) + errors["base"] = "unknown" + else: + # Flatten location + location = user_input.pop(CONF_LOCATION) + user_input[CONF_LATITUDE] = location[CONF_LATITUDE] + user_input[CONF_LONGITUDE] = location[CONF_LONGITUDE] + + return self.async_create_entry( + title=f"{user_input[CONF_LATITUDE]}, {user_input[CONF_LONGITUDE]}", + data=user_input, + ) + + suggested_values: Mapping[str, Any] = { + CONF_LOCATION: { + CONF_LATITUDE: self.hass.config.latitude, + CONF_LONGITUDE: self.hass.config.longitude, + } + } + + data_schema = self.add_suggested_values_to_schema(DATA_SCHEMA, suggested_values) + return self.async_show_form( + step_id="user", + data_schema=data_schema, + errors=errors, + ) + + async def _test_config(self, user_input: dict[str, Any]) -> None: + """Validate credentials.""" + client = WeatherKitApiClient( + key_id=user_input[CONF_KEY_ID], + service_id=user_input[CONF_SERVICE_ID], + team_id=user_input[CONF_TEAM_ID], + key_pem=user_input[CONF_KEY_PEM], + session=async_get_clientsession(self.hass), + ) + + location = user_input[CONF_LOCATION] + availability = await client.get_availability( + location[CONF_LATITUDE], + location[CONF_LONGITUDE], + ) + + if not availability: + raise WeatherKitUnsupportedLocationError( + "API does not support this location" + ) diff --git a/homeassistant/components/weatherkit/const.py b/homeassistant/components/weatherkit/const.py new file mode 100644 index 00000000000000..590ca65c9a9e2c --- /dev/null +++ b/homeassistant/components/weatherkit/const.py @@ -0,0 +1,16 @@ +"""Constants for WeatherKit.""" +from logging import Logger, getLogger + +LOGGER: Logger = getLogger(__package__) + +NAME = "Apple WeatherKit" +DOMAIN = "weatherkit" +ATTRIBUTION = ( + "Data provided by Apple Weather. " + "https://developer.apple.com/weatherkit/data-source-attribution/" +) + +CONF_KEY_ID = "key_id" +CONF_SERVICE_ID = "service_id" +CONF_TEAM_ID = "team_id" +CONF_KEY_PEM = "key_pem" diff --git a/homeassistant/components/weatherkit/coordinator.py b/homeassistant/components/weatherkit/coordinator.py new file mode 100644 index 00000000000000..a918ce0f850d03 --- /dev/null +++ b/homeassistant/components/weatherkit/coordinator.py @@ -0,0 +1,70 @@ +"""DataUpdateCoordinator for WeatherKit integration.""" +from __future__ import annotations + +from datetime import timedelta + +from apple_weatherkit import DataSetType +from apple_weatherkit.client import WeatherKitApiClient, WeatherKitApiClientError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, LOGGER + +REQUESTED_DATA_SETS = [ + DataSetType.CURRENT_WEATHER, + DataSetType.DAILY_FORECAST, + DataSetType.HOURLY_FORECAST, +] + + +class WeatherKitDataUpdateCoordinator(DataUpdateCoordinator): + """Class to manage fetching data from the API.""" + + config_entry: ConfigEntry + supported_data_sets: list[DataSetType] | None = None + + def __init__( + self, + hass: HomeAssistant, + client: WeatherKitApiClient, + ) -> None: + """Initialize.""" + self.client = client + super().__init__( + hass=hass, + logger=LOGGER, + name=DOMAIN, + update_interval=timedelta(minutes=15), + ) + + async def update_supported_data_sets(self): + """Obtain the supported data sets for this location and store them.""" + supported_data_sets = await self.client.get_availability( + self.config_entry.data[CONF_LATITUDE], + self.config_entry.data[CONF_LONGITUDE], + ) + + self.supported_data_sets = [ + data_set + for data_set in REQUESTED_DATA_SETS + if data_set in supported_data_sets + ] + + LOGGER.debug("Supported data sets: %s", self.supported_data_sets) + + async def _async_update_data(self): + """Update the current weather and forecasts.""" + try: + if not self.supported_data_sets: + await self.update_supported_data_sets() + + return await self.client.get_weather_data( + self.config_entry.data[CONF_LATITUDE], + self.config_entry.data[CONF_LONGITUDE], + self.supported_data_sets, + ) + except WeatherKitApiClientError as exception: + raise UpdateFailed(exception) from exception diff --git a/homeassistant/components/weatherkit/manifest.json b/homeassistant/components/weatherkit/manifest.json new file mode 100644 index 00000000000000..34a5d45ca1f3e5 --- /dev/null +++ b/homeassistant/components/weatherkit/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "weatherkit", + "name": "Apple WeatherKit", + "codeowners": ["@tjhorner"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/weatherkit", + "iot_class": "cloud_polling", + "requirements": ["apple_weatherkit==1.0.3"] +} diff --git a/homeassistant/components/weatherkit/strings.json b/homeassistant/components/weatherkit/strings.json new file mode 100644 index 00000000000000..4581028f209e98 --- /dev/null +++ b/homeassistant/components/weatherkit/strings.json @@ -0,0 +1,25 @@ +{ + "config": { + "step": { + "user": { + "title": "WeatherKit setup", + "description": "Enter your location details and WeatherKit authentication credentials below.", + "data": { + "name": "Name", + "location": "[%key:common::config_flow::data::location%]", + "key_id": "Key ID", + "team_id": "Apple team ID", + "service_id": "Service ID", + "key_pem": "Private key (.p8)" + } + } + }, + "error": { + "already_configured": "[%key:common::config_flow::abort::already_configured_location%]", + "unsupported_location": "Apple WeatherKit does not provide data for this location.", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + } + } +} diff --git a/homeassistant/components/weatherkit/weather.py b/homeassistant/components/weatherkit/weather.py new file mode 100644 index 00000000000000..07745680b010a5 --- /dev/null +++ b/homeassistant/components/weatherkit/weather.py @@ -0,0 +1,262 @@ +"""Weather entity for Apple WeatherKit integration.""" + +from typing import Any, cast + +from apple_weatherkit import DataSetType + +from homeassistant.components.weather import ( + ATTR_CONDITION_CLOUDY, + ATTR_CONDITION_EXCEPTIONAL, + ATTR_CONDITION_FOG, + ATTR_CONDITION_HAIL, + ATTR_CONDITION_LIGHTNING, + ATTR_CONDITION_PARTLYCLOUDY, + ATTR_CONDITION_POURING, + ATTR_CONDITION_RAINY, + ATTR_CONDITION_SNOWY, + ATTR_CONDITION_SNOWY_RAINY, + ATTR_CONDITION_SUNNY, + ATTR_CONDITION_WINDY, + Forecast, + SingleCoordinatorWeatherEntity, + WeatherEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_LATITUDE, + CONF_LONGITUDE, + UnitOfLength, + UnitOfPressure, + UnitOfSpeed, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import ATTRIBUTION, DOMAIN +from .coordinator import WeatherKitDataUpdateCoordinator + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Add a weather entity from a config_entry.""" + coordinator: WeatherKitDataUpdateCoordinator = hass.data[DOMAIN][ + config_entry.entry_id + ] + + async_add_entities([WeatherKitWeather(coordinator)]) + + +condition_code_to_hass = { + "BlowingDust": ATTR_CONDITION_WINDY, + "Clear": ATTR_CONDITION_SUNNY, + "Cloudy": ATTR_CONDITION_CLOUDY, + "Foggy": ATTR_CONDITION_FOG, + "Haze": ATTR_CONDITION_FOG, + "MostlyClear": ATTR_CONDITION_SUNNY, + "MostlyCloudy": ATTR_CONDITION_CLOUDY, + "PartlyCloudy": ATTR_CONDITION_PARTLYCLOUDY, + "Smoky": ATTR_CONDITION_FOG, + "Breezy": ATTR_CONDITION_WINDY, + "Windy": ATTR_CONDITION_WINDY, + "Drizzle": ATTR_CONDITION_RAINY, + "HeavyRain": ATTR_CONDITION_POURING, + "IsolatedThunderstorms": ATTR_CONDITION_LIGHTNING, + "Rain": ATTR_CONDITION_RAINY, + "SunShowers": ATTR_CONDITION_RAINY, + "ScatteredThunderstorms": ATTR_CONDITION_LIGHTNING, + "StrongStorms": ATTR_CONDITION_LIGHTNING, + "Thunderstorms": ATTR_CONDITION_LIGHTNING, + "Frigid": ATTR_CONDITION_SNOWY, + "Hail": ATTR_CONDITION_HAIL, + "Hot": ATTR_CONDITION_SUNNY, + "Flurries": ATTR_CONDITION_SNOWY, + "Sleet": ATTR_CONDITION_SNOWY, + "Snow": ATTR_CONDITION_SNOWY, + "SunFlurries": ATTR_CONDITION_SNOWY, + "WintryMix": ATTR_CONDITION_SNOWY, + "Blizzard": ATTR_CONDITION_SNOWY, + "BlowingSnow": ATTR_CONDITION_SNOWY, + "FreezingDrizzle": ATTR_CONDITION_SNOWY_RAINY, + "FreezingRain": ATTR_CONDITION_SNOWY_RAINY, + "HeavySnow": ATTR_CONDITION_SNOWY, + "Hurricane": ATTR_CONDITION_EXCEPTIONAL, + "TropicalStorm": ATTR_CONDITION_EXCEPTIONAL, +} + + +def _map_daily_forecast(forecast: dict[str, Any]) -> Forecast: + return { + "datetime": forecast["forecastStart"], + "condition": condition_code_to_hass[forecast["conditionCode"]], + "native_temperature": forecast["temperatureMax"], + "native_templow": forecast["temperatureMin"], + "native_precipitation": forecast["precipitationAmount"], + "precipitation_probability": forecast["precipitationChance"] * 100, + "uv_index": forecast["maxUvIndex"], + } + + +def _map_hourly_forecast(forecast: dict[str, Any]) -> Forecast: + return { + "datetime": forecast["forecastStart"], + "condition": condition_code_to_hass[forecast["conditionCode"]], + "native_temperature": forecast["temperature"], + "native_apparent_temperature": forecast["temperatureApparent"], + "native_dew_point": forecast.get("temperatureDewPoint"), + "native_pressure": forecast["pressure"], + "native_wind_gust_speed": forecast.get("windGust"), + "native_wind_speed": forecast["windSpeed"], + "wind_bearing": forecast.get("windDirection"), + "humidity": forecast["humidity"] * 100, + "native_precipitation": forecast.get("precipitationAmount"), + "precipitation_probability": forecast["precipitationChance"] * 100, + "cloud_coverage": forecast["cloudCover"] * 100, + "uv_index": forecast["uvIndex"], + } + + +class WeatherKitWeather( + SingleCoordinatorWeatherEntity[WeatherKitDataUpdateCoordinator] +): + """Weather entity for Apple WeatherKit integration.""" + + _attr_attribution = ATTRIBUTION + + _attr_has_entity_name = True + _attr_name = None + + _attr_native_temperature_unit = UnitOfTemperature.CELSIUS + _attr_native_pressure_unit = UnitOfPressure.MBAR + _attr_native_visibility_unit = UnitOfLength.KILOMETERS + _attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR + + def __init__( + self, + coordinator: WeatherKitDataUpdateCoordinator, + ) -> None: + """Initialise the platform with a data instance and site.""" + super().__init__(coordinator) + config_data = coordinator.config_entry.data + self._attr_unique_id = ( + f"{config_data[CONF_LATITUDE]}-{config_data[CONF_LONGITUDE]}" + ) + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, self._attr_unique_id)}, + manufacturer="Apple Weather", + ) + + @property + def supported_features(self) -> WeatherEntityFeature: + """Determine supported features based on available data sets reported by WeatherKit.""" + features = WeatherEntityFeature(0) + + if not self.coordinator.supported_data_sets: + return features + + if DataSetType.DAILY_FORECAST in self.coordinator.supported_data_sets: + features |= WeatherEntityFeature.FORECAST_DAILY + if DataSetType.HOURLY_FORECAST in self.coordinator.supported_data_sets: + features |= WeatherEntityFeature.FORECAST_HOURLY + return features + + @property + def data(self) -> dict[str, Any]: + """Return coordinator data.""" + return self.coordinator.data + + @property + def current_weather(self) -> dict[str, Any]: + """Return current weather data.""" + return self.data["currentWeather"] + + @property + def condition(self) -> str | None: + """Return the current condition.""" + condition_code = cast(str, self.current_weather.get("conditionCode")) + condition = condition_code_to_hass[condition_code] + + if condition == "sunny" and self.current_weather.get("daylight") is False: + condition = "clear-night" + + return condition + + @property + def native_temperature(self) -> float | None: + """Return the current temperature.""" + return self.current_weather.get("temperature") + + @property + def native_apparent_temperature(self) -> float | None: + """Return the current apparent_temperature.""" + return self.current_weather.get("temperatureApparent") + + @property + def native_dew_point(self) -> float | None: + """Return the current dew_point.""" + return self.current_weather.get("temperatureDewPoint") + + @property + def native_pressure(self) -> float | None: + """Return the current pressure.""" + return self.current_weather.get("pressure") + + @property + def humidity(self) -> float | None: + """Return the current humidity.""" + return cast(float, self.current_weather.get("humidity")) * 100 + + @property + def cloud_coverage(self) -> float | None: + """Return the current cloud_coverage.""" + return cast(float, self.current_weather.get("cloudCover")) * 100 + + @property + def uv_index(self) -> float | None: + """Return the current uv_index.""" + return self.current_weather.get("uvIndex") + + @property + def native_visibility(self) -> float | None: + """Return the current visibility.""" + return cast(float, self.current_weather.get("visibility")) / 1000 + + @property + def native_wind_gust_speed(self) -> float | None: + """Return the current wind_gust_speed.""" + return self.current_weather.get("windGust") + + @property + def native_wind_speed(self) -> float | None: + """Return the current wind_speed.""" + return self.current_weather.get("windSpeed") + + @property + def wind_bearing(self) -> float | None: + """Return the current wind_bearing.""" + return self.current_weather.get("windDirection") + + @callback + def _async_forecast_daily(self) -> list[Forecast] | None: + """Return the daily forecast.""" + daily_forecast = self.data.get("forecastDaily") + if not daily_forecast: + return None + + forecast = daily_forecast.get("days") + return [_map_daily_forecast(f) for f in forecast] + + @callback + def _async_forecast_hourly(self) -> list[Forecast] | None: + """Return the hourly forecast.""" + hourly_forecast = self.data.get("forecastHourly") + if not hourly_forecast: + return None + + forecast = hourly_forecast.get("hours") + return [_map_hourly_forecast(f) for f in forecast] diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index bbcbfa6ecb8e37..cef9e7bb706c35 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -5,12 +5,13 @@ import datetime as dt from functools import lru_cache, partial import json +import logging from typing import Any, cast import voluptuous as vol from homeassistant.auth.models import User -from homeassistant.auth.permissions.const import CAT_ENTITIES, POLICY_READ +from homeassistant.auth.permissions.const import POLICY_READ from homeassistant.const import ( EVENT_STATE_CHANGED, MATCH_ALL, @@ -51,7 +52,6 @@ from . import const, decorators, messages from .connection import ActiveConnection -from .const import ERR_NOT_FOUND from .messages import construct_event_message, construct_result_message ALL_SERVICE_DESCRIPTIONS_JSON_CACHE = "websocket_api_all_service_descriptions_json" @@ -270,7 +270,7 @@ def handle_get_states( states = _async_get_allowed_states(hass, connection) try: - serialized_states = [state.as_dict_json() for state in states] + serialized_states = [state.as_dict_json for state in states] except (ValueError, TypeError): pass else: @@ -281,7 +281,7 @@ def handle_get_states( serialized_states = [] for state in states: try: - serialized_states.append(state.as_dict_json()) + serialized_states.append(state.as_dict_json) except (ValueError, TypeError): connection.logger.error( "Unable to serialize to JSON. Bad data found at %s", @@ -358,7 +358,7 @@ def handle_subscribe_entities( # to succeed for the UI to show. try: serialized_states = [ - state.as_compressed_state_json() + state.as_compressed_state_json for state in states if not entity_ids or state.entity_id in entity_ids ] @@ -371,7 +371,7 @@ def handle_subscribe_entities( serialized_states = [] for state in states: try: - serialized_states.append(state.as_compressed_state_json()) + serialized_states.append(state.as_compressed_state_json) except (ValueError, TypeError): connection.logger.error( "Unable to serialize to JSON. Bad data found at %s", @@ -505,6 +505,7 @@ def _cached_template(template_str: str, hass: HomeAssistant) -> template.Templat vol.Optional("variables"): dict, vol.Optional("timeout"): vol.Coerce(float), vol.Optional("strict", default=False): bool, + vol.Optional("report_errors", default=False): bool, } ) @decorators.async_response @@ -513,19 +514,35 @@ async def handle_render_template( ) -> None: """Handle render_template command.""" template_str = msg["template"] - template_obj = _cached_template(template_str, hass) + report_errors: bool = msg["report_errors"] + if report_errors: + template_obj = template.Template(template_str, hass) + else: + template_obj = _cached_template(template_str, hass) variables = msg.get("variables") timeout = msg.get("timeout") - info = None + + @callback + def _error_listener(level: int, template_error: str) -> None: + connection.send_message( + messages.event_message( + msg["id"], + {"error": template_error, "level": logging.getLevelName(level)}, + ) + ) + + @callback + def _thread_safe_error_listener(level: int, template_error: str) -> None: + hass.loop.call_soon_threadsafe(_error_listener, level, template_error) if timeout: try: + log_fn = _thread_safe_error_listener if report_errors else None timed_out = await template_obj.async_render_will_timeout( - timeout, variables, strict=msg["strict"] + timeout, variables, strict=msg["strict"], log_fn=log_fn ) - except TemplateError as ex: - connection.send_error(msg["id"], const.ERR_TEMPLATE_ERROR, str(ex)) - return + except TemplateError: + timed_out = False if timed_out: connection.send_error( @@ -540,26 +557,32 @@ def _template_listener( event: EventType[EventStateChangedData] | None, updates: list[TrackTemplateResult], ) -> None: - nonlocal info track_template_result = updates.pop() result = track_template_result.result if isinstance(result, TemplateError): - connection.send_error(msg["id"], const.ERR_TEMPLATE_ERROR, str(result)) + if not report_errors: + return + connection.send_message( + messages.event_message( + msg["id"], {"error": str(result), "level": "ERROR"} + ) + ) return connection.send_message( messages.event_message( - msg["id"], {"result": result, "listeners": info.listeners} # type: ignore[attr-defined] + msg["id"], {"result": result, "listeners": info.listeners} ) ) try: + log_fn = _error_listener if report_errors else None info = async_track_template_result( hass, [TrackTemplate(template_obj, variables)], _template_listener, - raise_on_template_error=True, strict=msg["strict"], + log_fn=log_fn, ) except TemplateError as ex: connection.send_error(msg["id"], const.ERR_TEMPLATE_ERROR, str(ex)) @@ -572,47 +595,35 @@ def _template_listener( hass.loop.call_soon_threadsafe(info.async_refresh) +def _serialize_entity_sources( + entity_infos: dict[str, entity.EntityInfo] +) -> dict[str, Any]: + """Prepare a websocket response from a dict of entity sources.""" + return { + entity_id: {"domain": entity_info["domain"]} + for entity_id, entity_info in entity_infos.items() + } + + @callback -@decorators.websocket_command( - {vol.Required("type"): "entity/source", vol.Optional("entity_id"): [cv.entity_id]} -) +@decorators.websocket_command({vol.Required("type"): "entity/source"}) def handle_entity_source( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Handle entity source command.""" - raw_sources = entity.entity_sources(hass) + all_entity_sources = entity.entity_sources(hass) entity_perm = connection.user.permissions.check_entity - if "entity_id" not in msg: - if connection.user.permissions.access_all_entities(POLICY_READ): - sources = raw_sources - else: - sources = { - entity_id: source - for entity_id, source in raw_sources.items() - if entity_perm(entity_id, POLICY_READ) - } - - connection.send_result(msg["id"], sources) - return - - sources = {} - - for entity_id in msg["entity_id"]: - if not entity_perm(entity_id, POLICY_READ): - raise Unauthorized( - context=connection.context(msg), - permission=POLICY_READ, - perm_category=CAT_ENTITIES, - ) - - if (source := raw_sources.get(entity_id)) is None: - connection.send_error(msg["id"], ERR_NOT_FOUND, "Entity not found") - return - - sources[entity_id] = source + if connection.user.permissions.access_all_entities(POLICY_READ): + entity_sources = all_entity_sources + else: + entity_sources = { + entity_id: source + for entity_id, source in all_entity_sources.items() + if entity_perm(entity_id, POLICY_READ) + } - connection.send_result(msg["id"], sources) + connection.send_result(msg["id"], _serialize_entity_sources(entity_sources)) @decorators.websocket_command( @@ -713,12 +724,12 @@ async def handle_execute_script( context = connection.context(msg) script_obj = Script(hass, script_config, f"{const.DOMAIN} script", const.DOMAIN) - response = await script_obj.async_run(msg.get("variables"), context=context) + script_result = await script_obj.async_run(msg.get("variables"), context=context) connection.send_result( msg["id"], { "context": context, - "response": response, + "response": script_result.service_response if script_result else None, }, ) diff --git a/homeassistant/components/websocket_api/messages.py b/homeassistant/components/websocket_api/messages.py index e5fd5626302503..6e88c36c32818d 100644 --- a/homeassistant/components/websocket_api/messages.py +++ b/homeassistant/components/websocket_api/messages.py @@ -94,7 +94,9 @@ def _cached_event_message(event: Event) -> str: The IDEN_TEMPLATE is used which will be replaced with the actual iden in cached_event_message """ - return message_to_json({"id": IDEN_TEMPLATE, "type": "event", "event": event}) + return message_to_json( + {"id": IDEN_TEMPLATE, "type": "event", "event": event.as_dict()} + ) def cached_state_diff_message(iden: int, event: Event) -> str: @@ -139,7 +141,7 @@ def _state_diff_event(event: Event) -> dict: if (event_old_state := event.data["old_state"]) is None: return { ENTITY_EVENT_ADD: { - event_new_state.entity_id: event_new_state.as_compressed_state() + event_new_state.entity_id: event_new_state.as_compressed_state } } if TYPE_CHECKING: diff --git a/homeassistant/components/websocket_api/sensor.py b/homeassistant/components/websocket_api/sensor.py index 9377fcefd92e59..5857ead2c11283 100644 --- a/homeassistant/components/websocket_api/sensor.py +++ b/homeassistant/components/websocket_api/sensor.py @@ -34,7 +34,7 @@ def __init__(self) -> None: self.count = 0 async def async_added_to_hass(self) -> None: - """Added to hass.""" + """Handle addition to hass.""" self.async_on_remove( async_dispatcher_connect( self.hass, SIGNAL_WEBSOCKET_CONNECTED, self._update_count diff --git a/homeassistant/components/wiffi/__init__.py b/homeassistant/components/wiffi/__init__.py index 11ef186ba1508f..3a35ec1ed29421 100644 --- a/homeassistant/components/wiffi/__init__.py +++ b/homeassistant/components/wiffi/__init__.py @@ -144,7 +144,8 @@ class WiffiEntity(Entity): def __init__(self, device, metric, options): """Initialize the base elements of a wiffi entity.""" self._id = generate_unique_id(device, metric) - self._device_info = DeviceInfo( + self._attr_unique_id = self._id + self._attr_device_info = DeviceInfo( connections={(dr.CONNECTION_NETWORK_MAC, device.mac_address)}, identifiers={(DOMAIN, device.mac_address)}, manufacturer="stall.biz", @@ -153,7 +154,7 @@ def __init__(self, device, metric, options): sw_version=device.sw_version, configuration_url=device.configuration_url, ) - self._name = metric.description + self._attr_name = metric.description self._expiration_date = None self._value = None self._timeout = options.get(CONF_TIMEOUT, DEFAULT_TIMEOUT) @@ -173,26 +174,6 @@ async def async_added_to_hass(self): ) ) - @property - def device_info(self): - """Return wiffi device info which is shared between all entities of a device.""" - return self._device_info - - @property - def unique_id(self): - """Return unique id for entity.""" - return self._id - - @property - def name(self): - """Return entity name.""" - return self._name - - @property - def available(self): - """Return true if value is valid.""" - return self._value is not None - def reset_expiration_date(self): """Reset value expiration date. @@ -221,8 +202,10 @@ def _check_expiration_date(self): def _is_measurement_entity(self): """Measurement entities have a value in present time.""" - return not self._name.endswith("_gestern") and not self._is_metered_entity() + return ( + not self._attr_name.endswith("_gestern") and not self._is_metered_entity() + ) def _is_metered_entity(self): """Metered entities have a value that keeps increasing until reset.""" - return self._name.endswith("_pro_h") or self._name.endswith("_heute") + return self._attr_name.endswith("_pro_h") or self._attr_name.endswith("_heute") diff --git a/homeassistant/components/wiffi/binary_sensor.py b/homeassistant/components/wiffi/binary_sensor.py index d0647b2529701e..cb1e1da41d8485 100644 --- a/homeassistant/components/wiffi/binary_sensor.py +++ b/homeassistant/components/wiffi/binary_sensor.py @@ -39,13 +39,13 @@ class BoolEntity(WiffiEntity, BinarySensorEntity): def __init__(self, device, metric, options): """Initialize the entity.""" super().__init__(device, metric, options) - self._value = metric.value + self._attr_is_on = metric.value self.reset_expiration_date() @property - def is_on(self): - """Return the state of the entity.""" - return self._value + def available(self): + """Return true if value is valid.""" + return self._attr_is_on is not None @callback def _update_value_callback(self, device, metric): @@ -54,5 +54,5 @@ def _update_value_callback(self, device, metric): Called if a new message has been received from the wiffi device. """ self.reset_expiration_date() - self._value = metric.value + self._attr_is_on = metric.value self.async_write_ha_state() diff --git a/homeassistant/components/wiffi/sensor.py b/homeassistant/components/wiffi/sensor.py index 1036ac7986f9f4..e460a346bd7168 100644 --- a/homeassistant/components/wiffi/sensor.py +++ b/homeassistant/components/wiffi/sensor.py @@ -69,11 +69,13 @@ class NumberEntity(WiffiEntity, SensorEntity): def __init__(self, device, metric, options): """Initialize the entity.""" super().__init__(device, metric, options) - self._device_class = UOM_TO_DEVICE_CLASS_MAP.get(metric.unit_of_measurement) - self._unit_of_measurement = UOM_MAP.get( + self._attr_device_class = UOM_TO_DEVICE_CLASS_MAP.get( + metric.unit_of_measurement + ) + self._attr_native_unit_of_measurement = UOM_MAP.get( metric.unit_of_measurement, metric.unit_of_measurement ) - self._value = metric.value + self._attr_native_value = metric.value if self._is_measurement_entity(): self._attr_state_class = SensorStateClass.MEASUREMENT @@ -83,19 +85,9 @@ def __init__(self, device, metric, options): self.reset_expiration_date() @property - def device_class(self): - """Return the automatically determined device class.""" - return self._device_class - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement of this entity.""" - return self._unit_of_measurement - - @property - def native_value(self): - """Return the value of the entity.""" - return self._value + def available(self): + """Return true if value is valid.""" + return self._attr_native_value is not None @callback def _update_value_callback(self, device, metric): @@ -104,11 +96,11 @@ def _update_value_callback(self, device, metric): Called if a new message has been received from the wiffi device. """ self.reset_expiration_date() - self._unit_of_measurement = UOM_MAP.get( + self._attr_native_unit_of_measurement = UOM_MAP.get( metric.unit_of_measurement, metric.unit_of_measurement ) - self._value = metric.value + self._attr_native_value = metric.value self.async_write_ha_state() @@ -119,13 +111,13 @@ class StringEntity(WiffiEntity, SensorEntity): def __init__(self, device, metric, options): """Initialize the entity.""" super().__init__(device, metric, options) - self._value = metric.value + self._attr_native_value = metric.value self.reset_expiration_date() @property - def native_value(self): - """Return the value of the entity.""" - return self._value + def available(self): + """Return true if value is valid.""" + return self._attr_native_value is not None @callback def _update_value_callback(self, device, metric): @@ -134,5 +126,5 @@ def _update_value_callback(self, device, metric): Called if a new message has been received from the wiffi device. """ self.reset_expiration_date() - self._value = metric.value + self._attr_native_value = metric.value self.async_write_ha_state() diff --git a/homeassistant/components/wilight/switch.py b/homeassistant/components/wilight/switch.py index 101162302ae559..334d750b1e1c28 100644 --- a/homeassistant/components/wilight/switch.py +++ b/homeassistant/components/wilight/switch.py @@ -149,6 +149,7 @@ class WiLightValveSwitch(WiLightDevice, SwitchEntity): """Representation of a WiLights Valve switch.""" _attr_translation_key = "watering" + _attr_icon = ICON_WATERING @property def is_on(self) -> bool: @@ -237,11 +238,6 @@ def extra_state_attributes(self) -> dict[str, Any]: return attr - @property - def icon(self) -> str: - """Return the icon to use in the frontend.""" - return ICON_WATERING - async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" await self._client.turn_on(self._index) @@ -270,6 +266,7 @@ class WiLightValvePauseSwitch(WiLightDevice, SwitchEntity): """Representation of a WiLights Valve Pause switch.""" _attr_translation_key = "pause" + _attr_icon = ICON_PAUSE @property def is_on(self) -> bool: @@ -297,11 +294,6 @@ def extra_state_attributes(self) -> dict[str, Any]: return attr - @property - def icon(self) -> str: - """Return the icon to use in the frontend.""" - return ICON_PAUSE - async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" await self._client.turn_on(self._index) diff --git a/homeassistant/components/withings/__init__.py b/homeassistant/components/withings/__init__.py index 682efde88816c7..5e7337086392e0 100644 --- a/homeassistant/components/withings/__init__.py +++ b/homeassistant/components/withings/__init__.py @@ -5,7 +5,6 @@ from __future__ import annotations import asyncio -from typing import Any from aiohttp.web import Request, Response import voluptuous as vol @@ -17,12 +16,14 @@ async_import_client_credential, ) from homeassistant.components.webhook import ( + async_generate_id, async_unregister as async_unregister_webhook, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_CLIENT_ID, CONF_CLIENT_SECRET, + CONF_TOKEN, CONF_WEBHOOK_ID, Platform, ) @@ -33,12 +34,12 @@ from . import const from .common import ( - _LOGGER, async_get_data_manager, async_remove_data_manager, get_data_manager_by_webhook_id, json_message_response, ) +from .const import CONF_USE_WEBHOOK, CONFIG, LOGGER DOMAIN = const.DOMAIN PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] @@ -90,7 +91,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: conf[CONF_CLIENT_SECRET], ), ) - _LOGGER.warning( + LOGGER.warning( "Configuration of Withings integration OAuth2 credentials in YAML " "is deprecated and will be removed in a future release; Your " "existing OAuth Application Credentials have been imported into " @@ -103,33 +104,27 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Withings from a config entry.""" - config_updates: dict[str, Any] = {} - - # Add a unique id if it's an older config entry. - if entry.unique_id != entry.data["token"]["userid"] or not isinstance( - entry.unique_id, str - ): - config_updates["unique_id"] = str(entry.data["token"]["userid"]) - - # Add the webhook configuration. - if CONF_WEBHOOK_ID not in entry.data: - webhook_id = webhook.async_generate_id() - config_updates["data"] = { - **entry.data, - **{ - const.CONF_USE_WEBHOOK: hass.data[DOMAIN][const.CONFIG][ - const.CONF_USE_WEBHOOK - ], - CONF_WEBHOOK_ID: webhook_id, - }, + if CONF_USE_WEBHOOK not in entry.options: + new_data = entry.data.copy() + new_options = { + CONF_USE_WEBHOOK: new_data.get(CONF_USE_WEBHOOK, False), } + unique_id = str(entry.data[CONF_TOKEN]["userid"]) + if CONF_WEBHOOK_ID not in new_data: + new_data[CONF_WEBHOOK_ID] = async_generate_id() - if config_updates: - hass.config_entries.async_update_entry(entry, **config_updates) + hass.config_entries.async_update_entry( + entry, data=new_data, options=new_options, unique_id=unique_id + ) + use_webhook = hass.data[DOMAIN][CONFIG][CONF_USE_WEBHOOK] + if use_webhook is not None and use_webhook != entry.options[CONF_USE_WEBHOOK]: + new_options = entry.options.copy() + new_options |= {CONF_USE_WEBHOOK: use_webhook} + hass.config_entries.async_update_entry(entry, options=new_options) data_manager = await async_get_data_manager(hass, entry) - _LOGGER.debug("Confirming %s is authenticated to withings", data_manager.profile) + LOGGER.debug("Confirming %s is authenticated to withings", entry.title) await data_manager.poll_data_update_coordinator.async_config_entry_first_refresh() webhook.async_register( @@ -154,6 +149,7 @@ def async_call_later_callback(now) -> None: entry.async_on_unload(async_call_later(hass, 1, async_call_later_callback)) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(update_listener)) return True @@ -175,6 +171,11 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True +async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle options update.""" + await hass.config_entries.async_reload(entry.entry_id) + + async def async_webhook_handler( hass: HomeAssistant, webhook_id: str, request: Request ) -> Response | None: @@ -203,7 +204,7 @@ async def async_webhook_handler( data_manager = get_data_manager_by_webhook_id(hass, webhook_id) if not data_manager: - _LOGGER.error( + LOGGER.error( ( "Webhook id %s not handled by data manager. This is a bug and should be" " reported" diff --git a/homeassistant/components/withings/api.py b/homeassistant/components/withings/api.py new file mode 100644 index 00000000000000..f9739d3fb6f1ad --- /dev/null +++ b/homeassistant/components/withings/api.py @@ -0,0 +1,170 @@ +"""Api for Withings.""" +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable, Iterable +from typing import Any + +import arrow +import requests +from withings_api import AbstractWithingsApi, DateType +from withings_api.common import ( + GetSleepSummaryField, + MeasureGetMeasGroupCategory, + MeasureGetMeasResponse, + MeasureType, + NotifyAppli, + NotifyListResponse, + SleepGetSummaryResponse, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.config_entry_oauth2_flow import ( + AbstractOAuth2Implementation, + OAuth2Session, +) + +from .const import LOGGER + +_RETRY_COEFFICIENT = 0.5 + + +class ConfigEntryWithingsApi(AbstractWithingsApi): + """Withing API that uses HA resources.""" + + def __init__( + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + implementation: AbstractOAuth2Implementation, + ) -> None: + """Initialize object.""" + self._hass = hass + self.config_entry = config_entry + self._implementation = implementation + self.session = OAuth2Session(hass, config_entry, implementation) + + def _request( + self, path: str, params: dict[str, Any], method: str = "GET" + ) -> dict[str, Any]: + """Perform an async request.""" + asyncio.run_coroutine_threadsafe( + self.session.async_ensure_token_valid(), self._hass.loop + ).result() + + access_token = self.config_entry.data["token"]["access_token"] + response = requests.request( + method, + f"{self.URL}/{path}", + params=params, + headers={"Authorization": f"Bearer {access_token}"}, + timeout=10, + ) + return response.json() + + async def _do_retry(self, func: Callable[[], Awaitable[Any]], attempts=3) -> Any: + """Retry a function call. + + Withings' API occasionally and incorrectly throws errors. + Retrying the call tends to work. + """ + exception = None + for attempt in range(1, attempts + 1): + LOGGER.debug("Attempt %s of %s", attempt, attempts) + try: + return await func() + except Exception as exception1: # pylint: disable=broad-except + LOGGER.debug( + "Failed attempt %s of %s (%s)", attempt, attempts, exception1 + ) + # Make each backoff pause a little bit longer + await asyncio.sleep(_RETRY_COEFFICIENT * attempt) + exception = exception1 + continue + + if exception: + raise exception + + async def async_measure_get_meas( + self, + meastype: MeasureType | None = None, + category: MeasureGetMeasGroupCategory | None = None, + startdate: DateType | None = arrow.utcnow(), + enddate: DateType | None = arrow.utcnow(), + offset: int | None = None, + lastupdate: DateType | None = arrow.utcnow(), + ) -> MeasureGetMeasResponse: + """Get measurements.""" + + async def call_super() -> MeasureGetMeasResponse: + return await self._hass.async_add_executor_job( + self.measure_get_meas, + meastype, + category, + startdate, + enddate, + offset, + lastupdate, + ) + + return await self._do_retry(call_super) + + async def async_sleep_get_summary( + self, + data_fields: Iterable[GetSleepSummaryField], + startdateymd: DateType | None = arrow.utcnow(), + enddateymd: DateType | None = arrow.utcnow(), + offset: int | None = None, + lastupdate: DateType | None = arrow.utcnow(), + ) -> SleepGetSummaryResponse: + """Get sleep data.""" + + async def call_super() -> SleepGetSummaryResponse: + return await self._hass.async_add_executor_job( + self.sleep_get_summary, + data_fields, + startdateymd, + enddateymd, + offset, + lastupdate, + ) + + return await self._do_retry(call_super) + + async def async_notify_list( + self, appli: NotifyAppli | None = None + ) -> NotifyListResponse: + """List webhooks.""" + + async def call_super() -> NotifyListResponse: + return await self._hass.async_add_executor_job(self.notify_list, appli) + + return await self._do_retry(call_super) + + async def async_notify_subscribe( + self, + callbackurl: str, + appli: NotifyAppli | None = None, + comment: str | None = None, + ) -> None: + """Subscribe to webhook.""" + + async def call_super() -> None: + await self._hass.async_add_executor_job( + self.notify_subscribe, callbackurl, appli, comment + ) + + await self._do_retry(call_super) + + async def async_notify_revoke( + self, callbackurl: str | None = None, appli: NotifyAppli | None = None + ) -> None: + """Revoke webhook.""" + + async def call_super() -> None: + await self._hass.async_add_executor_job( + self.notify_revoke, callbackurl, appli + ) + + await self._do_retry(call_super) diff --git a/homeassistant/components/withings/application_credentials.py b/homeassistant/components/withings/application_credentials.py index e5c401d5e7430b..1d5b52466c4fbf 100644 --- a/homeassistant/components/withings/application_credentials.py +++ b/homeassistant/components/withings/application_credentials.py @@ -1,15 +1,17 @@ """application_credentials platform for Withings.""" +from typing import Any + from withings_api import AbstractWithingsApi, WithingsAuth from homeassistant.components.application_credentials import ( + AuthImplementation, AuthorizationServer, ClientCredential, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import config_entry_oauth2_flow -from .common import WithingsLocalOAuth2Implementation from .const import DOMAIN @@ -26,3 +28,50 @@ async def async_get_auth_implementation( token_url=f"{AbstractWithingsApi.URL}/v2/oauth2", ), ) + + +class WithingsLocalOAuth2Implementation(AuthImplementation): + """Oauth2 implementation that only uses the external url.""" + + async def _token_request(self, data: dict) -> dict: + """Make a token request and adapt Withings API reply.""" + new_token = await super()._token_request(data) + # Withings API returns habitual token data under json key "body": + # { + # "status": [{integer} Withings API response status], + # "body": { + # "access_token": [{string} Your new access_token], + # "expires_in": [{integer} Access token expiry delay in seconds], + # "token_type": [{string] HTTP Authorization Header format: Bearer], + # "scope": [{string} Scopes the user accepted], + # "refresh_token": [{string} Your new refresh_token], + # "userid": [{string} The Withings ID of the user] + # } + # } + # so we copy that to token root. + if body := new_token.pop("body", None): + new_token.update(body) + return new_token + + async def async_resolve_external_data(self, external_data: Any) -> dict: + """Resolve the authorization code to tokens.""" + return await self._token_request( + { + "action": "requesttoken", + "grant_type": "authorization_code", + "code": external_data["code"], + "redirect_uri": external_data["state"]["redirect_uri"], + } + ) + + async def _async_refresh_token(self, token: dict) -> dict: + """Refresh tokens.""" + new_token = await self._token_request( + { + "action": "requesttoken", + "grant_type": "refresh_token", + "client_id": self.client_id, + "refresh_token": token["refresh_token"], + } + ) + return {**token, **new_token} diff --git a/homeassistant/components/withings/binary_sensor.py b/homeassistant/components/withings/binary_sensor.py index 6b072030bda383..976774f23b34f8 100644 --- a/homeassistant/components/withings/binary_sensor.py +++ b/homeassistant/components/withings/binary_sensor.py @@ -14,13 +14,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .common import ( - BaseWithingsSensor, - UpdateType, - WithingsEntityDescription, - async_get_data_manager, -) +from .common import UpdateType, async_get_data_manager from .const import Measurement +from .entity import BaseWithingsSensor, WithingsEntityDescription @dataclass @@ -36,7 +32,7 @@ class WithingsBinarySensorEntityDescription( key=Measurement.IN_BED.value, measurement=Measurement.IN_BED, measure_type=NotifyAppli.BED_IN, - name="In bed", + translation_key="in_bed", icon="mdi:bed", update_type=UpdateType.WEBHOOK, device_class=BinarySensorDeviceClass.OCCUPANCY, diff --git a/homeassistant/components/withings/common.py b/homeassistant/components/withings/common.py index 17e3c551bcce55..5f0090ad9a6cfd 100644 --- a/homeassistant/components/withings/common.py +++ b/homeassistant/components/withings/common.py @@ -8,13 +8,10 @@ from datetime import timedelta from enum import IntEnum, StrEnum from http import HTTPStatus -import logging import re from typing import Any from aiohttp.web import Response -import requests -from withings_api import AbstractWithingsApi from withings_api.common import ( AuthFailedException, GetSleepSummaryField, @@ -22,37 +19,30 @@ MeasureType, MeasureTypes, NotifyAppli, - SleepGetSummaryResponse, UnauthorizedException, query_measure_groups, ) from homeassistant.components import webhook -from homeassistant.components.application_credentials import AuthImplementation from homeassistant.components.http import HomeAssistantView from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_WEBHOOK_ID from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers import config_entry_oauth2_flow -from homeassistant.helpers.config_entry_oauth2_flow import ( - AbstractOAuth2Implementation, - OAuth2Session, -) -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util import dt as dt_util from . import const -from .const import DOMAIN, Measurement +from .api import ConfigEntryWithingsApi +from .const import LOGGER, Measurement -_LOGGER = logging.getLogger(const.LOG_NAMESPACE) -_RETRY_COEFFICIENT = 0.5 NOT_AUTHENTICATED_ERROR = re.compile( f"^{HTTPStatus.UNAUTHORIZED},.*", re.IGNORECASE, ) DATA_UPDATED_SIGNAL = "withings_entity_state_updated" +SUBSCRIBE_DELAY = datetime.timedelta(seconds=5) +UNSUBSCRIBE_DELAY = datetime.timedelta(seconds=1) class UpdateType(StrEnum): @@ -62,20 +52,6 @@ class UpdateType(StrEnum): WEBHOOK = "webhook" -@dataclass -class WithingsEntityDescriptionMixin: - """Mixin for describing withings data.""" - - measurement: Measurement - measure_type: NotifyAppli | GetSleepSummaryField | MeasureType - update_type: UpdateType - - -@dataclass -class WithingsEntityDescription(EntityDescription, WithingsEntityDescriptionMixin): - """Immutable class for describing withings data.""" - - @dataclass class WebhookConfig: """Config for a webhook.""" @@ -129,40 +105,6 @@ class WebhookConfig: } -class ConfigEntryWithingsApi(AbstractWithingsApi): - """Withing API that uses HA resources.""" - - def __init__( - self, - hass: HomeAssistant, - config_entry: ConfigEntry, - implementation: AbstractOAuth2Implementation, - ) -> None: - """Initialize object.""" - self._hass = hass - self.config_entry = config_entry - self._implementation = implementation - self.session = OAuth2Session(hass, config_entry, implementation) - - def _request( - self, path: str, params: dict[str, Any], method: str = "GET" - ) -> dict[str, Any]: - """Perform an async request.""" - asyncio.run_coroutine_threadsafe( - self.session.async_ensure_token_valid(), self._hass.loop - ).result() - - access_token = self.config_entry.data["token"]["access_token"] - response = requests.request( - method, - f"{self.URL}/{path}", - params=params, - headers={"Authorization": f"Bearer {access_token}"}, - timeout=10, - ) - return response.json() - - def json_message_response(message: str, message_code: int) -> Response: """Produce common json output.""" return HomeAssistantView.json({"message": message, "code": message_code}) @@ -218,7 +160,6 @@ class DataManager: def __init__( self, hass: HomeAssistant, - profile: str, api: ConfigEntryWithingsApi, user_id: int, webhook_config: WebhookConfig, @@ -227,10 +168,9 @@ def __init__( self._hass = hass self._api = api self._user_id = user_id - self._profile = profile self._webhook_config = webhook_config - self._notify_subscribe_delay = datetime.timedelta(seconds=5) - self._notify_unsubscribe_delay = datetime.timedelta(seconds=1) + self._notify_subscribe_delay = SUBSCRIBE_DELAY + self._notify_unsubscribe_delay = UNSUBSCRIBE_DELAY self._is_available = True self._cancel_interval_update_interval: CALLBACK_TYPE | None = None @@ -239,7 +179,7 @@ def __init__( self.subscription_update_coordinator = DataUpdateCoordinator( hass, - _LOGGER, + LOGGER, name="subscription_update_coordinator", update_interval=timedelta(minutes=120), update_method=self.async_subscribe_webhook, @@ -248,7 +188,7 @@ def __init__( dict[MeasureType, Any] | None ]( hass, - _LOGGER, + LOGGER, name="poll_data_update_coordinator", update_interval=timedelta(minutes=120) if self._webhook_config.enabled @@ -271,11 +211,6 @@ def user_id(self) -> int: """Get the user_id of the authenticated user.""" return self._user_id - @property - def profile(self) -> str: - """Get the profile.""" - return self._profile - def async_start_polling_webhook_subscriptions(self) -> None: """Start polling webhook subscriptions (if enabled) to reconcile their setup.""" self.async_stop_polling_webhook_subscriptions() @@ -293,47 +228,21 @@ def async_stop_polling_webhook_subscriptions(self) -> None: self._cancel_subscription_update() self._cancel_subscription_update = None - async def _do_retry(self, func, attempts=3) -> Any: - """Retry a function call. - - Withings' API occasionally and incorrectly throws errors. - Retrying the call tends to work. - """ - exception = None - for attempt in range(1, attempts + 1): - _LOGGER.debug("Attempt %s of %s", attempt, attempts) - try: - return await func() - except Exception as exception1: # pylint: disable=broad-except - _LOGGER.debug( - "Failed attempt %s of %s (%s)", attempt, attempts, exception1 - ) - # Make each backoff pause a little bit longer - await asyncio.sleep(_RETRY_COEFFICIENT * attempt) - exception = exception1 - continue - - if exception: - raise exception - async def async_subscribe_webhook(self) -> None: """Subscribe the webhook to withings data updates.""" - return await self._do_retry(self._async_subscribe_webhook) - - async def _async_subscribe_webhook(self) -> None: - _LOGGER.debug("Configuring withings webhook") + LOGGER.debug("Configuring withings webhook") # On first startup, perform a fresh re-subscribe. Withings stops pushing data # if the webhook fails enough times but they don't remove the old subscription # config. This ensures the subscription is setup correctly and they start # pushing again. if self._subscribe_webhook_run_count == 0: - _LOGGER.debug("Refreshing withings webhook configs") + LOGGER.debug("Refreshing withings webhook configs") await self.async_unsubscribe_webhook() self._subscribe_webhook_run_count += 1 # Get the current webhooks. - response = await self._hass.async_add_executor_job(self._api.notify_list) + response = await self._api.async_notify_list() subscribed_applis = frozenset( profile.appli @@ -351,7 +260,7 @@ async def _async_subscribe_webhook(self) -> None: # Subscribe to each one. for appli in to_add_applis: - _LOGGER.debug( + LOGGER.debug( "Subscribing %s for %s in %s seconds", self._webhook_config.url, appli, @@ -360,21 +269,16 @@ async def _async_subscribe_webhook(self) -> None: # Withings will HTTP HEAD the callback_url and needs some downtime # between each call or there is a higher chance of failure. await asyncio.sleep(self._notify_subscribe_delay.total_seconds()) - await self._hass.async_add_executor_job( - self._api.notify_subscribe, self._webhook_config.url, appli - ) + await self._api.async_notify_subscribe(self._webhook_config.url, appli) async def async_unsubscribe_webhook(self) -> None: """Unsubscribe webhook from withings data updates.""" - return await self._do_retry(self._async_unsubscribe_webhook) - - async def _async_unsubscribe_webhook(self) -> None: # Get the current webhooks. - response = await self._hass.async_add_executor_job(self._api.notify_list) + response = await self._api.async_notify_list() # Revoke subscriptions. for profile in response.profiles: - _LOGGER.debug( + LOGGER.debug( "Unsubscribing %s for %s in %s seconds", profile.callbackurl, profile.appli, @@ -383,14 +287,15 @@ async def _async_unsubscribe_webhook(self) -> None: # Quick calls to Withings can result in the service returning errors. # Give them some time to cool down. await asyncio.sleep(self._notify_subscribe_delay.total_seconds()) - await self._hass.async_add_executor_job( - self._api.notify_revoke, profile.callbackurl, profile.appli - ) + await self._api.async_notify_revoke(profile.callbackurl, profile.appli) async def async_get_all_data(self) -> dict[MeasureType, Any] | None: """Update all withings data.""" try: - return await self._do_retry(self._async_get_all_data) + return { + **await self.async_get_measures(), + **await self.async_get_sleep_summary(), + } except Exception as exception: # User is not authenticated. if isinstance( @@ -401,21 +306,14 @@ async def async_get_all_data(self) -> dict[MeasureType, Any] | None: raise exception - async def _async_get_all_data(self) -> dict[Measurement, Any] | None: - _LOGGER.info("Updating all withings data") - return { - **await self.async_get_measures(), - **await self.async_get_sleep_summary(), - } - async def async_get_measures(self) -> dict[Measurement, Any]: """Get the measures data.""" - _LOGGER.debug("Updating withings measures") + LOGGER.debug("Updating withings measures") now = dt_util.utcnow() startdate = now - datetime.timedelta(days=7) - response = await self._hass.async_add_executor_job( - self._api.measure_get_meas, None, None, startdate, now, None, startdate + response = await self._api.async_measure_get_meas( + None, None, startdate, now, None, startdate ) # Sort from oldest to newest. @@ -438,7 +336,7 @@ async def async_get_measures(self) -> dict[Measurement, Any]: async def async_get_sleep_summary(self) -> dict[Measurement, Any]: """Get the sleep summary data.""" - _LOGGER.debug("Updating withing sleep summary") + LOGGER.debug("Updating withing sleep summary") now = dt_util.now() yesterday = now - datetime.timedelta(days=1) yesterday_noon = dt_util.start_of_local_day(yesterday) + datetime.timedelta( @@ -446,31 +344,28 @@ async def async_get_sleep_summary(self) -> dict[Measurement, Any]: ) yesterday_noon_utc = dt_util.as_utc(yesterday_noon) - def get_sleep_summary() -> SleepGetSummaryResponse: - return self._api.sleep_get_summary( - lastupdate=yesterday_noon_utc, - data_fields=[ - GetSleepSummaryField.BREATHING_DISTURBANCES_INTENSITY, - GetSleepSummaryField.DEEP_SLEEP_DURATION, - GetSleepSummaryField.DURATION_TO_SLEEP, - GetSleepSummaryField.DURATION_TO_WAKEUP, - GetSleepSummaryField.HR_AVERAGE, - GetSleepSummaryField.HR_MAX, - GetSleepSummaryField.HR_MIN, - GetSleepSummaryField.LIGHT_SLEEP_DURATION, - GetSleepSummaryField.REM_SLEEP_DURATION, - GetSleepSummaryField.RR_AVERAGE, - GetSleepSummaryField.RR_MAX, - GetSleepSummaryField.RR_MIN, - GetSleepSummaryField.SLEEP_SCORE, - GetSleepSummaryField.SNORING, - GetSleepSummaryField.SNORING_EPISODE_COUNT, - GetSleepSummaryField.WAKEUP_COUNT, - GetSleepSummaryField.WAKEUP_DURATION, - ], - ) - - response = await self._hass.async_add_executor_job(get_sleep_summary) + response = await self._api.async_sleep_get_summary( + lastupdate=yesterday_noon_utc, + data_fields=[ + GetSleepSummaryField.BREATHING_DISTURBANCES_INTENSITY, + GetSleepSummaryField.DEEP_SLEEP_DURATION, + GetSleepSummaryField.DURATION_TO_SLEEP, + GetSleepSummaryField.DURATION_TO_WAKEUP, + GetSleepSummaryField.HR_AVERAGE, + GetSleepSummaryField.HR_MAX, + GetSleepSummaryField.HR_MIN, + GetSleepSummaryField.LIGHT_SLEEP_DURATION, + GetSleepSummaryField.REM_SLEEP_DURATION, + GetSleepSummaryField.RR_AVERAGE, + GetSleepSummaryField.RR_MAX, + GetSleepSummaryField.RR_MIN, + GetSleepSummaryField.SLEEP_SCORE, + GetSleepSummaryField.SNORING, + GetSleepSummaryField.SNORING_EPISODE_COUNT, + GetSleepSummaryField.WAKEUP_COUNT, + GetSleepSummaryField.WAKEUP_DURATION, + ], + ) # Set the default to empty lists. raw_values: dict[GetSleepSummaryField, list[int]] = { @@ -522,7 +417,7 @@ def set_value(field: GetSleepSummaryField, func: Callable) -> None: async def async_webhook_data_updated(self, data_category: NotifyAppli) -> None: """Handle scenario when data is updated from a webook.""" - _LOGGER.debug("Withings webhook triggered") + LOGGER.debug("Withings webhook triggered") if data_category in { NotifyAppli.WEIGHT, NotifyAppli.CIRCULATORY, @@ -536,87 +431,6 @@ async def async_webhook_data_updated(self, data_category: NotifyAppli) -> None: ) -def get_attribute_unique_id( - description: WithingsEntityDescription, user_id: int -) -> str: - """Get a entity unique id for a user's attribute.""" - return f"withings_{user_id}_{description.measurement.value}" - - -class BaseWithingsSensor(Entity): - """Base class for withings sensors.""" - - _attr_should_poll = False - entity_description: WithingsEntityDescription - - def __init__( - self, data_manager: DataManager, description: WithingsEntityDescription - ) -> None: - """Initialize the Withings sensor.""" - self._data_manager = data_manager - self.entity_description = description - self._attr_name = ( - f"Withings {description.measurement.value} {data_manager.profile}" - ) - self._attr_unique_id = get_attribute_unique_id( - description, data_manager.user_id - ) - self._state_data: Any | None = None - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, str(data_manager.user_id))}, - name=data_manager.profile, - ) - - @property - def available(self) -> bool: - """Return True if entity is available.""" - if self.entity_description.update_type == UpdateType.POLL: - return self._data_manager.poll_data_update_coordinator.last_update_success - - if self.entity_description.update_type == UpdateType.WEBHOOK: - return self._data_manager.webhook_config.enabled and ( - self.entity_description.measurement - in self._data_manager.webhook_update_coordinator.data - ) - - return True - - @callback - def _on_poll_data_updated(self) -> None: - self._update_state_data( - self._data_manager.poll_data_update_coordinator.data or {} - ) - - @callback - def _on_webhook_data_updated(self) -> None: - self._update_state_data( - self._data_manager.webhook_update_coordinator.data or {} - ) - - def _update_state_data(self, data: dict[Measurement, Any]) -> None: - """Update the state data.""" - self._state_data = data.get(self.entity_description.measurement) - self.async_write_ha_state() - - async def async_added_to_hass(self) -> None: - """Register update dispatcher.""" - if self.entity_description.update_type == UpdateType.POLL: - self.async_on_remove( - self._data_manager.poll_data_update_coordinator.async_add_listener( - self._on_poll_data_updated - ) - ) - self._on_poll_data_updated() - - elif self.entity_description.update_type == UpdateType.WEBHOOK: - self.async_on_remove( - self._data_manager.webhook_update_coordinator.async_add_listener( - self._on_webhook_data_updated - ) - ) - self._on_webhook_data_updated() - - async def async_get_data_manager( hass: HomeAssistant, config_entry: ConfigEntry ) -> DataManager: @@ -626,12 +440,11 @@ async def async_get_data_manager( config_entry_data = hass.data[const.DOMAIN][config_entry.entry_id] if const.DATA_MANAGER not in config_entry_data: - profile: str = config_entry.data[const.PROFILE] - - _LOGGER.debug("Creating withings data manager for profile: %s", profile) + LOGGER.debug( + "Creating withings data manager for profile: %s", config_entry.title + ) config_entry_data[const.DATA_MANAGER] = DataManager( hass, - profile, ConfigEntryWithingsApi( hass=hass, config_entry=config_entry, @@ -645,7 +458,7 @@ async def async_get_data_manager( url=webhook.async_generate_url( hass, config_entry.data[CONF_WEBHOOK_ID] ), - enabled=config_entry.data[const.CONF_USE_WEBHOOK], + enabled=config_entry.options[const.CONF_USE_WEBHOOK], ), ) @@ -680,50 +493,3 @@ def get_all_data_managers(hass: HomeAssistant) -> tuple[DataManager, ...]: def async_remove_data_manager(hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Remove a data manager for a config entry.""" del hass.data[const.DOMAIN][config_entry.entry_id][const.DATA_MANAGER] - - -class WithingsLocalOAuth2Implementation(AuthImplementation): - """Oauth2 implementation that only uses the external url.""" - - async def _token_request(self, data: dict) -> dict: - """Make a token request and adapt Withings API reply.""" - new_token = await super()._token_request(data) - # Withings API returns habitual token data under json key "body": - # { - # "status": [{integer} Withings API response status], - # "body": { - # "access_token": [{string} Your new access_token], - # "expires_in": [{integer} Access token expiry delay in seconds], - # "token_type": [{string] HTTP Authorization Header format: Bearer], - # "scope": [{string} Scopes the user accepted], - # "refresh_token": [{string} Your new refresh_token], - # "userid": [{string} The Withings ID of the user] - # } - # } - # so we copy that to token root. - if body := new_token.pop("body", None): - new_token.update(body) - return new_token - - async def async_resolve_external_data(self, external_data: Any) -> dict: - """Resolve the authorization code to tokens.""" - return await self._token_request( - { - "action": "requesttoken", - "grant_type": "authorization_code", - "code": external_data["code"], - "redirect_uri": external_data["state"]["redirect_uri"], - } - ) - - async def _async_refresh_token(self, token: dict) -> dict: - """Refresh tokens.""" - new_token = await self._token_request( - { - "action": "requesttoken", - "grant_type": "refresh_token", - "client_id": self.client_id, - "refresh_token": token["refresh_token"], - } - ) - return {**token, **new_token} diff --git a/homeassistant/components/withings/config_flow.py b/homeassistant/components/withings/config_flow.py index b0fa1876d92554..4dd123468a01f1 100644 --- a/homeassistant/components/withings/config_flow.py +++ b/homeassistant/components/withings/config_flow.py @@ -8,23 +8,32 @@ import voluptuous as vol from withings_api.common import AuthScope +from homeassistant.components.webhook import async_generate_id +from homeassistant.config_entries import ConfigEntry, OptionsFlowWithConfigEntry +from homeassistant.const import CONF_TOKEN, CONF_WEBHOOK_ID +from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import config_entry_oauth2_flow -from homeassistant.util import slugify -from . import const +from .const import CONF_USE_WEBHOOK, DEFAULT_TITLE, DOMAIN class WithingsFlowHandler( - config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=const.DOMAIN + config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN ): """Handle a config flow.""" - DOMAIN = const.DOMAIN + DOMAIN = DOMAIN - # Temporarily holds authorization data during the profile step. - _current_data: dict[str, None | str | int] = {} - _reauth_profile: str | None = None + reauth_entry: ConfigEntry | None = None + + @staticmethod + @callback + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> WithingsOptionsFlowHandler: + """Get the options flow for this handler.""" + return WithingsOptionsFlowHandler(config_entry) @property def logger(self) -> logging.Logger: @@ -45,64 +54,56 @@ def extra_authorize_data(self) -> dict[str, str]: ) } - async def async_oauth_create_entry(self, data: dict[str, Any]) -> FlowResult: - """Override the create entry so user can select a profile.""" - self._current_data = data - return await self.async_step_profile(data) - - async def async_step_profile(self, data: dict[str, Any]) -> FlowResult: - """Prompt the user to select a user profile.""" - errors = {} - profile = data.get(const.PROFILE) or self._reauth_profile - - if profile: - existing_entries = [ - config_entry - for config_entry in self._async_current_entries() - if slugify(config_entry.data.get(const.PROFILE)) == slugify(profile) - ] - - if self._reauth_profile or not existing_entries: - new_data = {**self._current_data, **data, const.PROFILE: profile} - self._current_data = {} - return await self.async_step_finish(new_data) - - errors["base"] = "already_configured" - - return self.async_show_form( - step_id="profile", - data_schema=vol.Schema({vol.Required(const.PROFILE): str}), - errors=errors, + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Perform reauth upon an API authentication error.""" + self.reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] ) - - async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: - """Prompt user to re-authenticate.""" - self._reauth_profile = data.get(const.PROFILE) return await self.async_step_reauth_confirm() async def async_step_reauth_confirm( - self, data: dict[str, Any] | None = None + self, user_input: dict[str, Any] | None = None ) -> FlowResult: - """Prompt user to re-authenticate.""" - if data is not None: - return await self.async_step_user() + """Confirm reauth dialog.""" + if user_input is None: + return self.async_show_form(step_id="reauth_confirm") + return await self.async_step_user() - placeholders = {const.PROFILE: self._reauth_profile} + async def async_oauth_create_entry(self, data: dict[str, Any]) -> FlowResult: + """Create an entry for the flow, or update existing entry.""" + user_id = str(data[CONF_TOKEN]["userid"]) + if not self.reauth_entry: + await self.async_set_unique_id(user_id) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=DEFAULT_TITLE, + data={**data, CONF_WEBHOOK_ID: async_generate_id()}, + options={CONF_USE_WEBHOOK: False}, + ) - self.context.update({"title_placeholders": placeholders}) + if self.reauth_entry.unique_id == user_id: + self.hass.config_entries.async_update_entry(self.reauth_entry, data=data) + return self.async_abort(reason="reauth_successful") - return self.async_show_form( - step_id="reauth_confirm", - description_placeholders=placeholders, - ) + return self.async_abort(reason="wrong_account") - async def async_step_finish(self, data: dict[str, Any]) -> FlowResult: - """Finish the flow.""" - self._current_data = {} - await self.async_set_unique_id( - str(data["token"]["userid"]), raise_on_progress=False - ) - self._abort_if_unique_id_configured(data) +class WithingsOptionsFlowHandler(OptionsFlowWithConfigEntry): + """Withings Options flow handler.""" - return self.async_create_entry(title=data[const.PROFILE], data=data) + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Initialize form.""" + if user_input is not None: + return self.async_create_entry( + data=user_input, + ) + return self.async_show_form( + step_id="init", + data_schema=self.add_suggested_values_to_schema( + vol.Schema({vol.Required(CONF_USE_WEBHOOK): bool}), + self.options, + ), + ) diff --git a/homeassistant/components/withings/const.py b/homeassistant/components/withings/const.py index 02d8977c604ac7..545c7bfcb26a34 100644 --- a/homeassistant/components/withings/const.py +++ b/homeassistant/components/withings/const.py @@ -1,6 +1,8 @@ """Constants used by the Withings component.""" from enum import StrEnum +import logging +DEFAULT_TITLE = "Withings" CONF_PROFILES = "profiles" CONF_USE_WEBHOOK = "use_webhook" @@ -12,6 +14,8 @@ PROFILE = "profile" PUSH_HANDLER = "push_handler" +LOGGER = logging.getLogger(__package__) + class Measurement(StrEnum): """Measurement supported by the withings integration.""" diff --git a/homeassistant/components/withings/entity.py b/homeassistant/components/withings/entity.py new file mode 100644 index 00000000000000..f17d3ccf03c544 --- /dev/null +++ b/homeassistant/components/withings/entity.py @@ -0,0 +1,99 @@ +"""Base entity for Withings.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from withings_api.common import GetSleepSummaryField, MeasureType, NotifyAppli + +from homeassistant.core import callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity, EntityDescription + +from .common import DataManager, UpdateType +from .const import DOMAIN, Measurement + + +@dataclass +class WithingsEntityDescriptionMixin: + """Mixin for describing withings data.""" + + measurement: Measurement + measure_type: NotifyAppli | GetSleepSummaryField | MeasureType + update_type: UpdateType + + +@dataclass +class WithingsEntityDescription(EntityDescription, WithingsEntityDescriptionMixin): + """Immutable class for describing withings data.""" + + +class BaseWithingsSensor(Entity): + """Base class for withings sensors.""" + + _attr_should_poll = False + entity_description: WithingsEntityDescription + _attr_has_entity_name = True + + def __init__( + self, data_manager: DataManager, description: WithingsEntityDescription + ) -> None: + """Initialize the Withings sensor.""" + self._data_manager = data_manager + self.entity_description = description + self._attr_unique_id = ( + f"withings_{data_manager.user_id}_{description.measurement.value}" + ) + self._state_data: Any | None = None + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, str(data_manager.user_id))}, manufacturer="Withings" + ) + + @property + def available(self) -> bool: + """Return True if entity is available.""" + if self.entity_description.update_type == UpdateType.POLL: + return self._data_manager.poll_data_update_coordinator.last_update_success + + if self.entity_description.update_type == UpdateType.WEBHOOK: + return self._data_manager.webhook_config.enabled and ( + self.entity_description.measurement + in self._data_manager.webhook_update_coordinator.data + ) + + return True + + @callback + def _on_poll_data_updated(self) -> None: + self._update_state_data( + self._data_manager.poll_data_update_coordinator.data or {} + ) + + @callback + def _on_webhook_data_updated(self) -> None: + self._update_state_data( + self._data_manager.webhook_update_coordinator.data or {} + ) + + def _update_state_data(self, data: dict[Measurement, Any]) -> None: + """Update the state data.""" + self._state_data = data.get(self.entity_description.measurement) + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Register update dispatcher.""" + if self.entity_description.update_type == UpdateType.POLL: + self.async_on_remove( + self._data_manager.poll_data_update_coordinator.async_add_listener( + self._on_poll_data_updated + ) + ) + self._on_poll_data_updated() + + elif self.entity_description.update_type == UpdateType.WEBHOOK: + self.async_on_remove( + self._data_manager.webhook_update_coordinator.async_add_listener( + self._on_webhook_data_updated + ) + ) + self._on_webhook_data_updated() diff --git a/homeassistant/components/withings/manifest.json b/homeassistant/components/withings/manifest.json index 29201c7e66ef84..325205cb4d4c0f 100644 --- a/homeassistant/components/withings/manifest.json +++ b/homeassistant/components/withings/manifest.json @@ -1,7 +1,7 @@ { "domain": "withings", "name": "Withings", - "codeowners": ["@vangorra"], + "codeowners": ["@vangorra", "@joostlek"], "config_flow": true, "dependencies": ["application_credentials", "http", "webhook"], "documentation": "https://www.home-assistant.io/integrations/withings", diff --git a/homeassistant/components/withings/sensor.py b/homeassistant/components/withings/sensor.py index c2cdd89a17f714..e8798adae2f059 100644 --- a/homeassistant/components/withings/sensor.py +++ b/homeassistant/components/withings/sensor.py @@ -23,12 +23,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .common import ( - BaseWithingsSensor, - UpdateType, - WithingsEntityDescription, - async_get_data_manager, -) +from .common import UpdateType, async_get_data_manager from .const import ( SCORE_POINTS, UOM_BEATS_PER_MINUTE, @@ -37,6 +32,7 @@ UOM_MMHG, Measurement, ) +from .entity import BaseWithingsSensor, WithingsEntityDescription @dataclass @@ -51,7 +47,6 @@ class WithingsSensorEntityDescription( key=Measurement.WEIGHT_KG.value, measurement=Measurement.WEIGHT_KG, measure_type=MeasureType.WEIGHT, - name="Weight", native_unit_of_measurement=UnitOfMass.KILOGRAMS, device_class=SensorDeviceClass.WEIGHT, state_class=SensorStateClass.MEASUREMENT, @@ -61,7 +56,7 @@ class WithingsSensorEntityDescription( key=Measurement.FAT_MASS_KG.value, measurement=Measurement.FAT_MASS_KG, measure_type=MeasureType.FAT_MASS_WEIGHT, - name="Fat Mass", + translation_key="fat_mass", native_unit_of_measurement=UnitOfMass.KILOGRAMS, device_class=SensorDeviceClass.WEIGHT, state_class=SensorStateClass.MEASUREMENT, @@ -71,7 +66,7 @@ class WithingsSensorEntityDescription( key=Measurement.FAT_FREE_MASS_KG.value, measurement=Measurement.FAT_FREE_MASS_KG, measure_type=MeasureType.FAT_FREE_MASS, - name="Fat Free Mass", + translation_key="fat_free_mass", native_unit_of_measurement=UnitOfMass.KILOGRAMS, device_class=SensorDeviceClass.WEIGHT, state_class=SensorStateClass.MEASUREMENT, @@ -81,7 +76,7 @@ class WithingsSensorEntityDescription( key=Measurement.MUSCLE_MASS_KG.value, measurement=Measurement.MUSCLE_MASS_KG, measure_type=MeasureType.MUSCLE_MASS, - name="Muscle Mass", + translation_key="muscle_mass", native_unit_of_measurement=UnitOfMass.KILOGRAMS, device_class=SensorDeviceClass.WEIGHT, state_class=SensorStateClass.MEASUREMENT, @@ -91,7 +86,7 @@ class WithingsSensorEntityDescription( key=Measurement.BONE_MASS_KG.value, measurement=Measurement.BONE_MASS_KG, measure_type=MeasureType.BONE_MASS, - name="Bone Mass", + translation_key="bone_mass", native_unit_of_measurement=UnitOfMass.KILOGRAMS, device_class=SensorDeviceClass.WEIGHT, state_class=SensorStateClass.MEASUREMENT, @@ -101,7 +96,7 @@ class WithingsSensorEntityDescription( key=Measurement.HEIGHT_M.value, measurement=Measurement.HEIGHT_M, measure_type=MeasureType.HEIGHT, - name="Height", + translation_key="height", native_unit_of_measurement=UnitOfLength.METERS, device_class=SensorDeviceClass.DISTANCE, state_class=SensorStateClass.MEASUREMENT, @@ -112,7 +107,6 @@ class WithingsSensorEntityDescription( key=Measurement.TEMP_C.value, measurement=Measurement.TEMP_C, measure_type=MeasureType.TEMPERATURE, - name="Temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, @@ -122,7 +116,7 @@ class WithingsSensorEntityDescription( key=Measurement.BODY_TEMP_C.value, measurement=Measurement.BODY_TEMP_C, measure_type=MeasureType.BODY_TEMPERATURE, - name="Body Temperature", + translation_key="body_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, @@ -132,7 +126,7 @@ class WithingsSensorEntityDescription( key=Measurement.SKIN_TEMP_C.value, measurement=Measurement.SKIN_TEMP_C, measure_type=MeasureType.SKIN_TEMPERATURE, - name="Skin Temperature", + translation_key="skin_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, @@ -142,7 +136,7 @@ class WithingsSensorEntityDescription( key=Measurement.FAT_RATIO_PCT.value, measurement=Measurement.FAT_RATIO_PCT, measure_type=MeasureType.FAT_RATIO, - name="Fat Ratio", + translation_key="fat_ratio", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, update_type=UpdateType.POLL, @@ -151,7 +145,7 @@ class WithingsSensorEntityDescription( key=Measurement.DIASTOLIC_MMHG.value, measurement=Measurement.DIASTOLIC_MMHG, measure_type=MeasureType.DIASTOLIC_BLOOD_PRESSURE, - name="Diastolic Blood Pressure", + translation_key="diastolic_blood_pressure", native_unit_of_measurement=UOM_MMHG, state_class=SensorStateClass.MEASUREMENT, update_type=UpdateType.POLL, @@ -160,7 +154,7 @@ class WithingsSensorEntityDescription( key=Measurement.SYSTOLIC_MMGH.value, measurement=Measurement.SYSTOLIC_MMGH, measure_type=MeasureType.SYSTOLIC_BLOOD_PRESSURE, - name="Systolic Blood Pressure", + translation_key="systolic_blood_pressure", native_unit_of_measurement=UOM_MMHG, state_class=SensorStateClass.MEASUREMENT, update_type=UpdateType.POLL, @@ -169,7 +163,7 @@ class WithingsSensorEntityDescription( key=Measurement.HEART_PULSE_BPM.value, measurement=Measurement.HEART_PULSE_BPM, measure_type=MeasureType.HEART_RATE, - name="Heart Pulse", + translation_key="heart_pulse", native_unit_of_measurement=UOM_BEATS_PER_MINUTE, icon="mdi:heart-pulse", state_class=SensorStateClass.MEASUREMENT, @@ -179,7 +173,7 @@ class WithingsSensorEntityDescription( key=Measurement.SPO2_PCT.value, measurement=Measurement.SPO2_PCT, measure_type=MeasureType.SP02, - name="SP02", + translation_key="spo2", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, update_type=UpdateType.POLL, @@ -188,7 +182,7 @@ class WithingsSensorEntityDescription( key=Measurement.HYDRATION.value, measurement=Measurement.HYDRATION, measure_type=MeasureType.HYDRATION, - name="Hydration", + translation_key="hydration", native_unit_of_measurement=UnitOfMass.KILOGRAMS, device_class=SensorDeviceClass.WEIGHT, icon="mdi:water", @@ -200,7 +194,7 @@ class WithingsSensorEntityDescription( key=Measurement.PWV.value, measurement=Measurement.PWV, measure_type=MeasureType.PULSE_WAVE_VELOCITY, - name="Pulse Wave Velocity", + translation_key="pulse_wave_velocity", native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND, device_class=SensorDeviceClass.SPEED, state_class=SensorStateClass.MEASUREMENT, @@ -210,7 +204,7 @@ class WithingsSensorEntityDescription( key=Measurement.SLEEP_BREATHING_DISTURBANCES_INTENSITY.value, measurement=Measurement.SLEEP_BREATHING_DISTURBANCES_INTENSITY, measure_type=GetSleepSummaryField.BREATHING_DISTURBANCES_INTENSITY, - name="Breathing disturbances intensity", + translation_key="breathing_disturbances_intensity", state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, update_type=UpdateType.POLL, @@ -219,7 +213,7 @@ class WithingsSensorEntityDescription( key=Measurement.SLEEP_DEEP_DURATION_SECONDS.value, measurement=Measurement.SLEEP_DEEP_DURATION_SECONDS, measure_type=GetSleepSummaryField.DEEP_SLEEP_DURATION, - name="Deep sleep", + translation_key="deep_sleep", native_unit_of_measurement=UnitOfTime.SECONDS, icon="mdi:sleep", device_class=SensorDeviceClass.DURATION, @@ -231,7 +225,7 @@ class WithingsSensorEntityDescription( key=Measurement.SLEEP_TOSLEEP_DURATION_SECONDS.value, measurement=Measurement.SLEEP_TOSLEEP_DURATION_SECONDS, measure_type=GetSleepSummaryField.DURATION_TO_SLEEP, - name="Time to sleep", + translation_key="time_to_sleep", native_unit_of_measurement=UnitOfTime.SECONDS, icon="mdi:sleep", device_class=SensorDeviceClass.DURATION, @@ -243,7 +237,7 @@ class WithingsSensorEntityDescription( key=Measurement.SLEEP_TOWAKEUP_DURATION_SECONDS.value, measurement=Measurement.SLEEP_TOWAKEUP_DURATION_SECONDS, measure_type=GetSleepSummaryField.DURATION_TO_WAKEUP, - name="Time to wakeup", + translation_key="time_to_wakeup", native_unit_of_measurement=UnitOfTime.SECONDS, icon="mdi:sleep-off", device_class=SensorDeviceClass.DURATION, @@ -255,7 +249,7 @@ class WithingsSensorEntityDescription( key=Measurement.SLEEP_HEART_RATE_AVERAGE.value, measurement=Measurement.SLEEP_HEART_RATE_AVERAGE, measure_type=GetSleepSummaryField.HR_AVERAGE, - name="Average heart rate", + translation_key="average_heart_rate", native_unit_of_measurement=UOM_BEATS_PER_MINUTE, icon="mdi:heart-pulse", state_class=SensorStateClass.MEASUREMENT, @@ -266,6 +260,7 @@ class WithingsSensorEntityDescription( key=Measurement.SLEEP_HEART_RATE_MAX.value, measurement=Measurement.SLEEP_HEART_RATE_MAX, measure_type=GetSleepSummaryField.HR_MAX, + translation_key="fat_mass", name="Maximum heart rate", native_unit_of_measurement=UOM_BEATS_PER_MINUTE, icon="mdi:heart-pulse", @@ -277,7 +272,7 @@ class WithingsSensorEntityDescription( key=Measurement.SLEEP_HEART_RATE_MIN.value, measurement=Measurement.SLEEP_HEART_RATE_MIN, measure_type=GetSleepSummaryField.HR_MIN, - name="Minimum heart rate", + translation_key="maximum_heart_rate", native_unit_of_measurement=UOM_BEATS_PER_MINUTE, icon="mdi:heart-pulse", state_class=SensorStateClass.MEASUREMENT, @@ -288,7 +283,7 @@ class WithingsSensorEntityDescription( key=Measurement.SLEEP_LIGHT_DURATION_SECONDS.value, measurement=Measurement.SLEEP_LIGHT_DURATION_SECONDS, measure_type=GetSleepSummaryField.LIGHT_SLEEP_DURATION, - name="Light sleep", + translation_key="light_sleep", native_unit_of_measurement=UnitOfTime.SECONDS, icon="mdi:sleep", device_class=SensorDeviceClass.DURATION, @@ -300,7 +295,7 @@ class WithingsSensorEntityDescription( key=Measurement.SLEEP_REM_DURATION_SECONDS.value, measurement=Measurement.SLEEP_REM_DURATION_SECONDS, measure_type=GetSleepSummaryField.REM_SLEEP_DURATION, - name="REM sleep", + translation_key="rem_sleep", native_unit_of_measurement=UnitOfTime.SECONDS, icon="mdi:sleep", device_class=SensorDeviceClass.DURATION, @@ -312,7 +307,7 @@ class WithingsSensorEntityDescription( key=Measurement.SLEEP_RESPIRATORY_RATE_AVERAGE.value, measurement=Measurement.SLEEP_RESPIRATORY_RATE_AVERAGE, measure_type=GetSleepSummaryField.RR_AVERAGE, - name="Average respiratory rate", + translation_key="average_respiratory_rate", native_unit_of_measurement=UOM_BREATHS_PER_MINUTE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, @@ -322,7 +317,7 @@ class WithingsSensorEntityDescription( key=Measurement.SLEEP_RESPIRATORY_RATE_MAX.value, measurement=Measurement.SLEEP_RESPIRATORY_RATE_MAX, measure_type=GetSleepSummaryField.RR_MAX, - name="Maximum respiratory rate", + translation_key="maximum_respiratory_rate", native_unit_of_measurement=UOM_BREATHS_PER_MINUTE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, @@ -332,7 +327,7 @@ class WithingsSensorEntityDescription( key=Measurement.SLEEP_RESPIRATORY_RATE_MIN.value, measurement=Measurement.SLEEP_RESPIRATORY_RATE_MIN, measure_type=GetSleepSummaryField.RR_MIN, - name="Minimum respiratory rate", + translation_key="minimum_respiratory_rate", native_unit_of_measurement=UOM_BREATHS_PER_MINUTE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, @@ -342,7 +337,7 @@ class WithingsSensorEntityDescription( key=Measurement.SLEEP_SCORE.value, measurement=Measurement.SLEEP_SCORE, measure_type=GetSleepSummaryField.SLEEP_SCORE, - name="Sleep score", + translation_key="sleep_score", native_unit_of_measurement=SCORE_POINTS, icon="mdi:medal", state_class=SensorStateClass.MEASUREMENT, @@ -353,7 +348,7 @@ class WithingsSensorEntityDescription( key=Measurement.SLEEP_SNORING.value, measurement=Measurement.SLEEP_SNORING, measure_type=GetSleepSummaryField.SNORING, - name="Snoring", + translation_key="snoring", state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, update_type=UpdateType.POLL, @@ -362,7 +357,7 @@ class WithingsSensorEntityDescription( key=Measurement.SLEEP_SNORING_EPISODE_COUNT.value, measurement=Measurement.SLEEP_SNORING_EPISODE_COUNT, measure_type=GetSleepSummaryField.SNORING_EPISODE_COUNT, - name="Snoring episode count", + translation_key="snoring_episode_count", state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, update_type=UpdateType.POLL, @@ -371,7 +366,7 @@ class WithingsSensorEntityDescription( key=Measurement.SLEEP_WAKEUP_COUNT.value, measurement=Measurement.SLEEP_WAKEUP_COUNT, measure_type=GetSleepSummaryField.WAKEUP_COUNT, - name="Wakeup count", + translation_key="wakeup_count", native_unit_of_measurement=UOM_FREQUENCY, icon="mdi:sleep-off", state_class=SensorStateClass.MEASUREMENT, @@ -382,7 +377,7 @@ class WithingsSensorEntityDescription( key=Measurement.SLEEP_WAKEUP_DURATION_SECONDS.value, measurement=Measurement.SLEEP_WAKEUP_DURATION_SECONDS, measure_type=GetSleepSummaryField.WAKEUP_DURATION, - name="Wakeup time", + translation_key="wakeup_time", native_unit_of_measurement=UnitOfTime.SECONDS, icon="mdi:sleep-off", device_class=SensorDeviceClass.DURATION, diff --git a/homeassistant/components/withings/strings.json b/homeassistant/components/withings/strings.json index 8f8a32c95e7e2b..22718b305ecefe 100644 --- a/homeassistant/components/withings/strings.json +++ b/homeassistant/components/withings/strings.json @@ -1,18 +1,12 @@ { "config": { - "flow_title": "{profile}", "step": { - "profile": { - "title": "User Profile.", - "description": "Provide a unique profile name for this data. Typically this is the name of the profile you selected in the previous step.", - "data": { "profile": "Profile Name" } - }, "pick_implementation": { "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", - "description": "The \"{profile}\" profile needs to be re-authenticated in order to continue receiving Withings data." + "description": "The Withings integration needs to re-authenticate your account" } }, "error": { @@ -27,5 +21,113 @@ "create_entry": { "default": "Successfully authenticated with Withings." } + }, + "options": { + "step": { + "init": { + "data": { + "use_webhook": "Use webhooks" + } + } + } + }, + "entity": { + "binary_sensor": { + "in_bed": { + "name": "In bed" + } + }, + "sensor": { + "fat_mass": { + "name": "Fat mass" + }, + "fat_free_mass": { + "name": "Fat free mass" + }, + "muscle_mass": { + "name": "Muscle mass" + }, + "bone_mass": { + "name": "Bone mass" + }, + "height": { + "name": "Height" + }, + "body_temperature": { + "name": "Body temperature" + }, + "skin_temperature": { + "name": "Skin temperature" + }, + "fat_ratio": { + "name": "Fat ratio" + }, + "diastolic_blood_pressure": { + "name": "Diastolic blood pressure" + }, + "systolic_blood_pressure": { + "name": "Systolic blood pressure" + }, + "heart_pulse": { + "name": "Heart pulse" + }, + "spo2": { + "name": "SpO2" + }, + "hydration": { + "name": "Hydration" + }, + "pulse_wave_velocity": { + "name": "Pulse wave velocity" + }, + "breathing_disturbances_intensity": { + "name": "Breathing disturbances intensity" + }, + "deep_sleep": { + "name": "Deep sleep" + }, + "time_to_sleep": { + "name": "Time to sleep" + }, + "time_to_wakeup": { + "name": "Time to wakeup" + }, + "average_heart_rate": { + "name": "Average heart rate" + }, + "maximum_heart_rate": { + "name": "Maximum heart rate" + }, + "light_sleep": { + "name": "Light sleep" + }, + "rem_sleep": { + "name": "REM sleep" + }, + "average_respiratory_rate": { + "name": "Average respiratory rate" + }, + "maximum_respiratory_rate": { + "name": "Maximum respiratory rate" + }, + "minimum_respiratory_rate": { + "name": "Minimum respiratory rate" + }, + "sleep_score": { + "name": "Sleep score" + }, + "snoring": { + "name": "Snoring" + }, + "snoring_episode_count": { + "name": "Snoring episode count" + }, + "wakeup_count": { + "name": "Wakeup count" + }, + "wakeup_time": { + "name": "Wakeup time" + } + } } } diff --git a/homeassistant/components/wled/button.py b/homeassistant/components/wled/button.py index 2f9e11627636e3..430ee067486d68 100644 --- a/homeassistant/components/wled/button.py +++ b/homeassistant/components/wled/button.py @@ -28,7 +28,6 @@ class WLEDRestartButton(WLEDEntity, ButtonEntity): _attr_device_class = ButtonDeviceClass.RESTART _attr_entity_category = EntityCategory.CONFIG - _attr_name = "Restart" def __init__(self, coordinator: WLEDDataUpdateCoordinator) -> None: """Initialize the button entity.""" diff --git a/homeassistant/components/wled/coordinator.py b/homeassistant/components/wled/coordinator.py index 9ba3fd2cb3d026..6f3bae03bfa3bc 100644 --- a/homeassistant/components/wled/coordinator.py +++ b/homeassistant/components/wled/coordinator.py @@ -46,7 +46,7 @@ def __init__( @property def has_master_light(self) -> bool: - """Return if the coordinated device has an master light.""" + """Return if the coordinated device has a master light.""" return self.keep_master_light or ( self.data is not None and len(self.data.state.segments) > 1 ) diff --git a/homeassistant/components/wled/light.py b/homeassistant/components/wled/light.py index 1eb8074bbc1546..6675118e565756 100644 --- a/homeassistant/components/wled/light.py +++ b/homeassistant/components/wled/light.py @@ -52,7 +52,7 @@ class WLEDMasterLight(WLEDEntity, LightEntity): _attr_color_mode = ColorMode.BRIGHTNESS _attr_icon = "mdi:led-strip-variant" - _attr_name = "Master" + _attr_translation_key = "main" _attr_supported_features = LightEntityFeature.TRANSITION _attr_supported_color_modes = {ColorMode.BRIGHTNESS} @@ -200,7 +200,7 @@ async def async_turn_off(self, **kwargs: Any) -> None: # WLED uses 100ms per unit, so 10 = 1 second. transition = round(kwargs[ATTR_TRANSITION] * 10) - # If there is no master control, and only 1 segment, handle the + # If there is no master control, and only 1 segment, handle the master if not self.coordinator.has_master_light: await self.coordinator.wled.master(on=False, transition=transition) return diff --git a/homeassistant/components/wled/select.py b/homeassistant/components/wled/select.py index c31f8e1277e372..977c76025ac4fc 100644 --- a/homeassistant/components/wled/select.py +++ b/homeassistant/components/wled/select.py @@ -50,7 +50,6 @@ class WLEDLiveOverrideSelect(WLEDEntity, SelectEntity): _attr_entity_category = EntityCategory.CONFIG _attr_icon = "mdi:theater" - _attr_name = "Live override" _attr_translation_key = "live_override" def __init__(self, coordinator: WLEDDataUpdateCoordinator) -> None: @@ -75,7 +74,7 @@ class WLEDPresetSelect(WLEDEntity, SelectEntity): """Defined a WLED Preset select.""" _attr_icon = "mdi:playlist-play" - _attr_name = "Preset" + _attr_translation_key = "preset" def __init__(self, coordinator: WLEDDataUpdateCoordinator) -> None: """Initialize WLED .""" @@ -106,7 +105,7 @@ class WLEDPlaylistSelect(WLEDEntity, SelectEntity): """Define a WLED Playlist select.""" _attr_icon = "mdi:play-speed" - _attr_name = "Playlist" + _attr_translation_key = "playlist" def __init__(self, coordinator: WLEDDataUpdateCoordinator) -> None: """Initialize WLED playlist.""" diff --git a/homeassistant/components/wled/sensor.py b/homeassistant/components/wled/sensor.py index 668b90159b5465..7d1431c093bfef 100644 --- a/homeassistant/components/wled/sensor.py +++ b/homeassistant/components/wled/sensor.py @@ -50,7 +50,7 @@ class WLEDSensorEntityDescription( SENSORS: tuple[WLEDSensorEntityDescription, ...] = ( WLEDSensorEntityDescription( key="estimated_current", - name="Estimated current", + translation_key="estimated_current", native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, @@ -60,13 +60,13 @@ class WLEDSensorEntityDescription( ), WLEDSensorEntityDescription( key="info_leds_count", - name="LED count", + translation_key="info_leds_count", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda device: device.info.leds.count, ), WLEDSensorEntityDescription( key="info_leds_max_power", - name="Max current", + translation_key="info_leds_max_power", native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE, entity_category=EntityCategory.DIAGNOSTIC, device_class=SensorDeviceClass.CURRENT, @@ -75,7 +75,7 @@ class WLEDSensorEntityDescription( ), WLEDSensorEntityDescription( key="uptime", - name="Uptime", + translation_key="uptime", device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -83,7 +83,7 @@ class WLEDSensorEntityDescription( ), WLEDSensorEntityDescription( key="free_heap", - name="Free memory", + translation_key="free_heap", icon="mdi:memory", native_unit_of_measurement=UnitOfInformation.BYTES, state_class=SensorStateClass.MEASUREMENT, @@ -94,7 +94,7 @@ class WLEDSensorEntityDescription( ), WLEDSensorEntityDescription( key="wifi_signal", - name="Wi-Fi signal", + translation_key="wifi_signal", icon="mdi:wifi", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -104,7 +104,7 @@ class WLEDSensorEntityDescription( ), WLEDSensorEntityDescription( key="wifi_rssi", - name="Wi-Fi RSSI", + translation_key="wifi_rssi", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, state_class=SensorStateClass.MEASUREMENT, @@ -114,7 +114,7 @@ class WLEDSensorEntityDescription( ), WLEDSensorEntityDescription( key="wifi_channel", - name="Wi-Fi channel", + translation_key="wifi_channel", icon="mdi:wifi", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -122,7 +122,7 @@ class WLEDSensorEntityDescription( ), WLEDSensorEntityDescription( key="wifi_bssid", - name="Wi-Fi BSSID", + translation_key="wifi_bssid", icon="mdi:wifi", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -130,7 +130,7 @@ class WLEDSensorEntityDescription( ), WLEDSensorEntityDescription( key="ip", - name="IP", + translation_key="ip", icon="mdi:ip-network", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda device: device.info.ip, diff --git a/homeassistant/components/wled/strings.json b/homeassistant/components/wled/strings.json index 9fc6573b112d25..5791732dfbe296 100644 --- a/homeassistant/components/wled/strings.json +++ b/homeassistant/components/wled/strings.json @@ -32,13 +32,68 @@ } }, "entity": { + "light": { + "main": { + "name": "Main" + } + }, "select": { "live_override": { + "name": "Live override", "state": { "0": "[%key:common::state::off%]", "1": "[%key:common::state::on%]", "2": "Until device restarts" } + }, + "preset": { + "name": "Preset" + }, + "playlist": { + "name": "Playlist" + } + }, + "sensor": { + "estimated_current": { + "name": "Estimated current" + }, + "info_leds_count": { + "name": "LED count" + }, + "info_leds_max_power": { + "name": "Max current" + }, + "uptime": { + "name": "Uptime" + }, + "free_heap": { + "name": "Free memory" + }, + "wifi_signal": { + "name": "Wi-Fi signal" + }, + "wifi_rssi": { + "name": "Wi-Fi RSSI" + }, + "wifi_channel": { + "name": "Wi-Fi channel" + }, + "wifi_bssid": { + "name": "Wi-Fi BSSID" + }, + "ip": { + "name": "IP" + } + }, + "switch": { + "nightlight": { + "name": "Nightlight" + }, + "sync_send": { + "name": "Sync send" + }, + "sync_receive": { + "name": "Sync receive" } } }, diff --git a/homeassistant/components/wled/switch.py b/homeassistant/components/wled/switch.py index 99b875c16424f1..680684e96dfbfc 100644 --- a/homeassistant/components/wled/switch.py +++ b/homeassistant/components/wled/switch.py @@ -55,7 +55,7 @@ class WLEDNightlightSwitch(WLEDEntity, SwitchEntity): _attr_icon = "mdi:weather-night" _attr_entity_category = EntityCategory.CONFIG - _attr_name = "Nightlight" + _attr_translation_key = "nightlight" def __init__(self, coordinator: WLEDDataUpdateCoordinator) -> None: """Initialize WLED nightlight switch.""" @@ -93,7 +93,7 @@ class WLEDSyncSendSwitch(WLEDEntity, SwitchEntity): _attr_icon = "mdi:upload-network-outline" _attr_entity_category = EntityCategory.CONFIG - _attr_name = "Sync send" + _attr_translation_key = "sync_send" def __init__(self, coordinator: WLEDDataUpdateCoordinator) -> None: """Initialize WLED sync send switch.""" @@ -126,7 +126,7 @@ class WLEDSyncReceiveSwitch(WLEDEntity, SwitchEntity): _attr_icon = "mdi:download-network-outline" _attr_entity_category = EntityCategory.CONFIG - _attr_name = "Sync receive" + _attr_translation_key = "sync_receive" def __init__(self, coordinator: WLEDDataUpdateCoordinator) -> None: """Initialize WLED sync receive switch.""" diff --git a/homeassistant/components/wled/update.py b/homeassistant/components/wled/update.py index 75546fdac1a64f..954279366beb67 100644 --- a/homeassistant/components/wled/update.py +++ b/homeassistant/components/wled/update.py @@ -36,7 +36,6 @@ class WLEDUpdateEntity(WLEDEntity, UpdateEntity): UpdateEntityFeature.INSTALL | UpdateEntityFeature.SPECIFIC_VERSION ) _attr_title = "WLED" - _attr_name = "Firmware" def __init__(self, coordinator: WLEDDataUpdateCoordinator) -> None: """Initialize the update entity.""" diff --git a/homeassistant/components/wolflink/sensor.py b/homeassistant/components/wolflink/sensor.py index 60883a0acf5dab..b4d60011658667 100644 --- a/homeassistant/components/wolflink/sensor.py +++ b/homeassistant/components/wolflink/sensor.py @@ -57,14 +57,10 @@ def __init__(self, coordinator, wolf_object: Parameter, device_id) -> None: """Initialize.""" super().__init__(coordinator) self.wolf_object = wolf_object - self.device_id = device_id + self._attr_name = wolf_object.name + self._attr_unique_id = f"{device_id}:{wolf_object.parameter_id}" self._state = None - @property - def name(self): - """Return the name.""" - return f"{self.wolf_object.name}" - @property def native_value(self): """Return the state. Wolf Client is returning only changed values so we need to store old value here.""" @@ -83,52 +79,26 @@ def extra_state_attributes(self): "parent": self.wolf_object.parent, } - @property - def unique_id(self): - """Return a unique_id for this entity.""" - return f"{self.device_id}:{self.wolf_object.parameter_id}" - class WolfLinkHours(WolfLinkSensor): """Class for hour based entities.""" - @property - def icon(self): - """Icon to display in the front Aend.""" - return "mdi:clock" - - @property - def native_unit_of_measurement(self): - """Return the unit the value is expressed in.""" - return UnitOfTime.HOURS + _attr_icon = "mdi:clock" + _attr_native_unit_of_measurement = UnitOfTime.HOURS class WolfLinkTemperature(WolfLinkSensor): """Class for temperature based entities.""" - @property - def device_class(self): - """Return the device_class.""" - return SensorDeviceClass.TEMPERATURE - - @property - def native_unit_of_measurement(self): - """Return the unit the value is expressed in.""" - return UnitOfTemperature.CELSIUS + _attr_device_class = SensorDeviceClass.TEMPERATURE + _attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS class WolfLinkPressure(WolfLinkSensor): """Class for pressure based entities.""" - @property - def device_class(self): - """Return the device_class.""" - return SensorDeviceClass.PRESSURE - - @property - def native_unit_of_measurement(self): - """Return the unit the value is expressed in.""" - return UnitOfPressure.BAR + _attr_device_class = SensorDeviceClass.PRESSURE + _attr_native_unit_of_measurement = UnitOfPressure.BAR class WolfLinkPercentage(WolfLinkSensor): diff --git a/homeassistant/components/workday/binary_sensor.py b/homeassistant/components/workday/binary_sensor.py index 6b6dfbffa5d94a..ad18c8863d68c0 100644 --- a/homeassistant/components/workday/binary_sensor.py +++ b/homeassistant/components/workday/binary_sensor.py @@ -129,7 +129,13 @@ async def async_setup_entry( workdays: list[str] = entry.options[CONF_WORKDAYS] year: int = (dt_util.now() + timedelta(days=days_offset)).year - obj_holidays: HolidayBase = country_holidays(country, subdiv=province, years=year) + cls: HolidayBase = country_holidays(country, subdiv=province, years=year) + obj_holidays: HolidayBase = country_holidays( + country, + subdiv=province, + years=year, + language=cls.default_language, + ) # Add custom holidays try: diff --git a/homeassistant/components/ws66i/media_player.py b/homeassistant/components/ws66i/media_player.py index b5c87fbc0f31da..7119002cbc4f27 100644 --- a/homeassistant/components/ws66i/media_player.py +++ b/homeassistant/components/ws66i/media_player.py @@ -46,6 +46,14 @@ class Ws66iZone(CoordinatorEntity[Ws66iDataUpdateCoordinator], MediaPlayerEntity _attr_has_entity_name = True _attr_name = None + _attr_supported_features = ( + MediaPlayerEntityFeature.VOLUME_MUTE + | MediaPlayerEntityFeature.VOLUME_SET + | MediaPlayerEntityFeature.VOLUME_STEP + | MediaPlayerEntityFeature.TURN_ON + | MediaPlayerEntityFeature.TURN_OFF + | MediaPlayerEntityFeature.SELECT_SOURCE + ) def __init__( self, @@ -64,18 +72,10 @@ def __init__( self._zone_id_idx: int = data_idx self._status: ZoneStatus = coordinator.data[data_idx] self._attr_source_list = ws66i_data.sources.name_list - self._attr_unique_id = f"{entry_id}_{self._zone_id}" - self._attr_supported_features = ( - MediaPlayerEntityFeature.VOLUME_MUTE - | MediaPlayerEntityFeature.VOLUME_SET - | MediaPlayerEntityFeature.VOLUME_STEP - | MediaPlayerEntityFeature.TURN_ON - | MediaPlayerEntityFeature.TURN_OFF - | MediaPlayerEntityFeature.SELECT_SOURCE - ) + self._attr_unique_id = f"{entry_id}_{zone_id}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, str(self.unique_id))}, - name=f"Zone {self._zone_id}", + name=f"Zone {zone_id}", manufacturer="Soundavo", model="WS66i 6-Zone Amplifier", ) diff --git a/homeassistant/components/xbox/base_sensor.py b/homeassistant/components/xbox/base_sensor.py index ffbbee8637d6bf..9aecb100df0a62 100644 --- a/homeassistant/components/xbox/base_sensor.py +++ b/homeassistant/components/xbox/base_sensor.py @@ -20,11 +20,15 @@ def __init__( super().__init__(coordinator) self.xuid = xuid self.attribute = attribute - - @property - def unique_id(self) -> str: - """Return a unique, Home Assistant friendly identifier for this entity.""" - return f"{self.xuid}_{self.attribute}" + self._attr_unique_id = f"{xuid}_{attribute}" + self._attr_entity_registry_enabled_default = attribute == "online" + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, "xbox_live")}, + manufacturer="Microsoft", + model="Xbox Live", + name="Xbox Live", + ) @property def data(self) -> PresenceData | None: @@ -61,19 +65,3 @@ def entity_picture(self) -> str | None: query = dict(url.query) query.pop("mode", None) return str(url.with_query(query)) - - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - return self.attribute == "online" - - @property - def device_info(self) -> DeviceInfo: - """Return a device description for device registry.""" - return DeviceInfo( - entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, "xbox_live")}, - manufacturer="Microsoft", - model="Xbox Live", - name="Xbox Live", - ) diff --git a/homeassistant/components/xiaomi_miio/button.py b/homeassistant/components/xiaomi_miio/button.py index 9ed9b780911cd4..e5e11b85e58dc5 100644 --- a/homeassistant/components/xiaomi_miio/button.py +++ b/homeassistant/components/xiaomi_miio/button.py @@ -169,8 +169,12 @@ def __init__(self, device, entry, unique_id, coordinator, description): async def async_press(self) -> None: """Press the button.""" method = getattr(self._device, self.entity_description.method_press) - await self._try_command( - self.entity_description.method_press_error_message, - method, - self.entity_description.method_press_params, - ) + params = self.entity_description.method_press_params + if params is not None: + await self._try_command( + self.entity_description.method_press_error_message, method, params + ) + else: + await self._try_command( + self.entity_description.method_press_error_message, method + ) diff --git a/homeassistant/components/yalexs_ble/manifest.json b/homeassistant/components/yalexs_ble/manifest.json index 3aefeea048a75a..cbff581d29608d 100644 --- a/homeassistant/components/yalexs_ble/manifest.json +++ b/homeassistant/components/yalexs_ble/manifest.json @@ -12,5 +12,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/yalexs_ble", "iot_class": "local_push", - "requirements": ["yalexs-ble==2.2.3"] + "requirements": ["yalexs-ble==2.3.0"] } diff --git a/homeassistant/components/yamaha_musiccast/__init__.py b/homeassistant/components/yamaha_musiccast/__init__.py index c3851074365881..9e8b8fed530f32 100644 --- a/homeassistant/components/yamaha_musiccast/__init__.py +++ b/homeassistant/components/yamaha_musiccast/__init__.py @@ -136,24 +136,9 @@ def __init__( ) -> None: """Initialize the MusicCast entity.""" super().__init__(coordinator) - self._enabled_default = enabled_default - self._icon = icon - self._name = name - - @property - def name(self) -> str: - """Return the name of the entity.""" - return self._name - - @property - def icon(self) -> str: - """Return the mdi icon of the entity.""" - return self._icon - - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - return self._enabled_default + self._attr_entity_registry_enabled_default = enabled_default + self._attr_icon = icon + self._attr_name = name class MusicCastDeviceEntity(MusicCastEntity): diff --git a/homeassistant/components/yamaha_musiccast/select.py b/homeassistant/components/yamaha_musiccast/select.py index a8ca6162c91aaf..8ef9df1ba2f9df 100644 --- a/homeassistant/components/yamaha_musiccast/select.py +++ b/homeassistant/components/yamaha_musiccast/select.py @@ -24,37 +24,39 @@ async def async_setup_entry( for capability in coordinator.data.capabilities: if isinstance(capability, OptionSetter): - select_entities.append(SelectableCapapility(coordinator, capability)) + select_entities.append(SelectableCapability(coordinator, capability)) for zone, data in coordinator.data.zones.items(): for capability in data.capabilities: if isinstance(capability, OptionSetter): select_entities.append( - SelectableCapapility(coordinator, capability, zone) + SelectableCapability(coordinator, capability, zone) ) async_add_entities(select_entities) -class SelectableCapapility(MusicCastCapabilityEntity, SelectEntity): +class SelectableCapability(MusicCastCapabilityEntity, SelectEntity): """Representation of a MusicCast Select entity.""" capability: OptionSetter + def __init__( + self, + coordinator: MusicCastDataUpdateCoordinator, + capability: OptionSetter, + zone_id: str | None = None, + ) -> None: + """Initialize the MusicCast Select entity.""" + MusicCastCapabilityEntity.__init__(self, coordinator, capability, zone_id) + self._attr_options = list(capability.options.values()) + self._attr_translation_key = TRANSLATION_KEY_MAPPING.get(capability.id) + async def async_select_option(self, option: str) -> None: """Select the given option.""" value = {val: key for key, val in self.capability.options.items()}[option] await self.capability.set(value) - - @property - def translation_key(self) -> str | None: - """Return the translation key to translate the entity's states.""" - return TRANSLATION_KEY_MAPPING.get(self.capability.id) - - @property - def options(self) -> list[str]: - """Return the list possible options.""" - return list(self.capability.options.values()) + self._attr_translation_key = TRANSLATION_KEY_MAPPING.get(self.capability.id) @property def current_option(self) -> str | None: diff --git a/homeassistant/components/yardian/coordinator.py b/homeassistant/components/yardian/coordinator.py index 526ee3c42ab26f..e7102f9c74bd5a 100644 --- a/homeassistant/components/yardian/coordinator.py +++ b/homeassistant/components/yardian/coordinator.py @@ -39,7 +39,6 @@ def __init__( hass, _LOGGER, name=entry.title, - update_method=self._async_update_data, update_interval=SCAN_INTERVAL, always_update=False, ) diff --git a/homeassistant/components/yeelight/manifest.json b/homeassistant/components/yeelight/manifest.json index 993cc6ca4faf15..e510a58b3e7d4c 100644 --- a/homeassistant/components/yeelight/manifest.json +++ b/homeassistant/components/yeelight/manifest.json @@ -17,7 +17,7 @@ "iot_class": "local_push", "loggers": ["async_upnp_client", "yeelight"], "quality_scale": "platinum", - "requirements": ["yeelight==0.7.13", "async-upnp-client==0.35.0"], + "requirements": ["yeelight==0.7.13", "async-upnp-client==0.35.1"], "zeroconf": [ { "type": "_miio._udp.local.", diff --git a/homeassistant/components/yolink/manifest.json b/homeassistant/components/yolink/manifest.json index ced0d527c7dbde..7322c58ae04fbe 100644 --- a/homeassistant/components/yolink/manifest.json +++ b/homeassistant/components/yolink/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["auth", "application_credentials"], "documentation": "https://www.home-assistant.io/integrations/yolink", "iot_class": "cloud_push", - "requirements": ["yolink-api==0.3.0"] + "requirements": ["yolink-api==0.3.1"] } diff --git a/homeassistant/components/yolink/sensor.py b/homeassistant/components/yolink/sensor.py index e4d0aa38fbee89..451b486acd21b9 100644 --- a/homeassistant/components/yolink/sensor.py +++ b/homeassistant/components/yolink/sensor.py @@ -92,6 +92,7 @@ class YoLinkSensorEntityDescription( ATTR_DEVICE_LEAK_SENSOR, ATTR_DEVICE_MOTION_SENSOR, ATTR_DEVICE_POWER_FAILURE_ALARM, + ATTR_DEVICE_SIREN, ATTR_DEVICE_SMART_REMOTER, ATTR_DEVICE_TH_SENSOR, ATTR_DEVICE_VIBRATION_SENSOR, diff --git a/homeassistant/components/zamg/manifest.json b/homeassistant/components/zamg/manifest.json index 3ff7612d47ebec..df17672231e15f 100644 --- a/homeassistant/components/zamg/manifest.json +++ b/homeassistant/components/zamg/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zamg", "iot_class": "cloud_polling", - "requirements": ["zamg==0.2.4"] + "requirements": ["zamg==0.3.0"] } diff --git a/homeassistant/components/zamg/weather.py b/homeassistant/components/zamg/weather.py index ff98496bd40edd..98e08106dca11c 100644 --- a/homeassistant/components/zamg/weather.py +++ b/homeassistant/components/zamg/weather.py @@ -32,6 +32,10 @@ class ZamgWeather(CoordinatorEntity, WeatherEntity): """Representation of a weather condition.""" _attr_attribution = ATTRIBUTION + _attr_native_temperature_unit = UnitOfTemperature.CELSIUS + _attr_native_pressure_unit = UnitOfPressure.HPA + _attr_native_wind_speed_unit = UnitOfSpeed.METERS_PER_SECOND + _attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS def __init__( self, coordinator: ZamgDataUpdateCoordinator, name: str, station_id: str @@ -48,16 +52,6 @@ def __init__( configuration_url=MANUFACTURER_URL, name=coordinator.name, ) - # set units of ZAMG API - self._attr_native_temperature_unit = UnitOfTemperature.CELSIUS - self._attr_native_pressure_unit = UnitOfPressure.HPA - self._attr_native_wind_speed_unit = UnitOfSpeed.METERS_PER_SECOND - self._attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS - - @property - def condition(self) -> str | None: - """Return the current condition.""" - return None @property def native_temperature(self) -> float | None: diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index b85f9f0fd83657..bf0984d3989ef0 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -11,7 +11,7 @@ import logging import re import sys -from typing import Any, Final, cast +from typing import TYPE_CHECKING, Any, Final, cast import voluptuous as vol from zeroconf import ( @@ -98,16 +98,43 @@ @dataclass(slots=True) class ZeroconfServiceInfo(BaseServiceInfo): - """Prepared info from mDNS entries.""" + """Prepared info from mDNS entries. - host: str - addresses: list[str] + The ip_address is the most recently updated address + that is not a link local or unspecified address. + + The ip_addresses are all addresses in order of most + recently updated to least recently updated. + + The host is the string representation of the ip_address. + + The addresses are the string representations of the + ip_addresses. + + It is recommended to use the ip_address to determine + the address to connect to as it will be the most + recently updated address that is not a link local + or unspecified address. + """ + + ip_address: IPv4Address | IPv6Address + ip_addresses: list[IPv4Address | IPv6Address] port: int | None hostname: str type: str name: str properties: dict[str, Any] + @property + def host(self) -> str: + """Return the host.""" + return _stringify_ip_address(self.ip_address) + + @property + def addresses(self) -> list[str]: + """Return the addresses.""" + return [_stringify_ip_address(ip_address) for ip_address in self.ip_addresses] + @bind_hass async def async_get_instance(hass: HomeAssistant) -> HaZeroconf: @@ -303,7 +330,8 @@ def _match_against_data( if key not in match_data: return False match_val = matcher[key] - assert isinstance(match_val, str) + if TYPE_CHECKING: + assert isinstance(match_val, str) if not _memorized_fnmatch(match_data[key], match_val): return False @@ -485,12 +513,14 @@ def _async_process_service_update( continue if ATTR_PROPERTIES in matcher: matcher_props = matcher[ATTR_PROPERTIES] - assert isinstance(matcher_props, dict) + if TYPE_CHECKING: + assert isinstance(matcher_props, dict) if not _match_against_props(matcher_props, props): continue matcher_domain = matcher["domain"] - assert isinstance(matcher_domain, str) + if TYPE_CHECKING: + assert isinstance(matcher_domain, str) context = { "source": config_entries.SOURCE_ZEROCONF, } @@ -516,11 +546,11 @@ def async_get_homekit_discovery( Return the domain to forward the discovery data to """ - if not (model := props.get(HOMEKIT_MODEL_LOWER) or props.get(HOMEKIT_MODEL_UPPER)): + if not ( + model := props.get(HOMEKIT_MODEL_LOWER) or props.get(HOMEKIT_MODEL_UPPER) + ) or not isinstance(model, str): return None - assert isinstance(model, str) - for split_str in _HOMEKIT_MODEL_SPLITS: key = (model.split(split_str))[0] if split_str else model if discovery := homekit_model_lookups.get(key): @@ -533,10 +563,8 @@ def async_get_homekit_discovery( return None -@lru_cache(maxsize=256) # matches to the cache in zeroconf itself -def _stringify_ip_address(ip_addr: IPv4Address | IPv6Address) -> str: - """Stringify an IP address.""" - return str(ip_addr) +# matches to the cache in zeroconf itself +_stringify_ip_address = lru_cache(maxsize=256)(str) def info_from_service(service: AsyncServiceInfo) -> ZeroconfServiceInfo | None: @@ -544,14 +572,18 @@ def info_from_service(service: AsyncServiceInfo) -> ZeroconfServiceInfo | None: # See https://ietf.org/rfc/rfc6763.html#section-6.4 and # https://ietf.org/rfc/rfc6763.html#section-6.5 for expected encodings # for property keys and values - if not (ip_addresses := service.ip_addresses_by_version(IPVersion.All)): + if not (maybe_ip_addresses := service.ip_addresses_by_version(IPVersion.All)): return None - host: str | None = None + if TYPE_CHECKING: + ip_addresses = cast(list[IPv4Address | IPv6Address], maybe_ip_addresses) + else: + ip_addresses = maybe_ip_addresses + ip_address: IPv4Address | IPv6Address | None = None for ip_addr in ip_addresses: if not ip_addr.is_link_local and not ip_addr.is_unspecified: - host = _stringify_ip_address(ip_addr) + ip_address = ip_addr break - if not host: + if not ip_address: return None # Service properties are always bytes if they are set from the network. @@ -568,8 +600,8 @@ def info_from_service(service: AsyncServiceInfo) -> ZeroconfServiceInfo | None: assert service.server is not None, "server cannot be none if there are addresses" return ZeroconfServiceInfo( - host=host, - addresses=[_stringify_ip_address(ip_addr) for ip_addr in ip_addresses], + ip_address=ip_address, + ip_addresses=ip_addresses, port=service.port, hostname=service.server, type=service.type, diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index 79b7e514f51088..d81ed1dfaaa382 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["zeroconf"], "quality_scale": "internal", - "requirements": ["zeroconf==0.88.0"] + "requirements": ["zeroconf==0.112.0"] } diff --git a/homeassistant/components/zerproc/light.py b/homeassistant/components/zerproc/light.py index 884f87d36f6fc4..c6be3c70e659ec 100644 --- a/homeassistant/components/zerproc/light.py +++ b/homeassistant/components/zerproc/light.py @@ -88,6 +88,12 @@ class ZerprocLight(LightEntity): def __init__(self, light) -> None: """Initialize a Zerproc light.""" self._light = light + self._attr_unique_id = light.address + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, light.address)}, + manufacturer="Zerproc", + name=light.name, + ) async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" @@ -108,20 +114,6 @@ async def async_will_remove_from_hass(self) -> None: "Exception disconnecting from %s", self._light.address, exc_info=True ) - @property - def unique_id(self): - """Return the ID of this light.""" - return self._light.address - - @property - def device_info(self) -> DeviceInfo: - """Device info for this light.""" - return DeviceInfo( - identifiers={(DOMAIN, self.unique_id)}, - manufacturer="Zerproc", - name=self._light.name, - ) - async def async_turn_on(self, **kwargs: Any) -> None: """Instruct the light to turn on.""" if ATTR_BRIGHTNESS in kwargs or ATTR_HS_COLOR in kwargs: diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index 1c4c3e776d064b..bd181d82a33005 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -1,5 +1,6 @@ """Support for Zigbee Home Automation devices.""" import asyncio +import contextlib import copy import logging import os @@ -32,14 +33,15 @@ CONF_USB_PATH, CONF_ZIGPY, DATA_ZHA, - DATA_ZHA_CONFIG, - DATA_ZHA_GATEWAY, DOMAIN, PLATFORMS, SIGNAL_ADD_ENTITIES, RadioType, ) +from .core.device import get_device_automation_triggers from .core.discovery import GROUP_PROBE +from .core.helpers import ZHAData, get_zha_data +from .radio_manager import ZhaRadioManager DEVICE_CONFIG_SCHEMA_ENTRY = vol.Schema({vol.Optional(CONF_TYPE): cv.string}) ZHA_CONFIG_SCHEMA = { @@ -77,11 +79,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up ZHA from config.""" - hass.data[DATA_ZHA] = {} - - if DOMAIN in config: - conf = config[DOMAIN] - hass.data[DATA_ZHA][DATA_ZHA_CONFIG] = conf + zha_data = ZHAData() + zha_data.yaml_config = config.get(DOMAIN, {}) + hass.data[DATA_ZHA] = zha_data return True @@ -116,14 +116,12 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b data[CONF_DEVICE][CONF_DEVICE_PATH] = cleaned_path hass.config_entries.async_update_entry(config_entry, data=data) - zha_data = hass.data.setdefault(DATA_ZHA, {}) - config = zha_data.get(DATA_ZHA_CONFIG, {}) - - for platform in PLATFORMS: - zha_data.setdefault(platform, []) + zha_data = get_zha_data(hass) - if config.get(CONF_ENABLE_QUIRKS, True): - setup_quirks(custom_quirks_path=config.get(CONF_CUSTOM_QUIRKS_PATH)) + if zha_data.yaml_config.get(CONF_ENABLE_QUIRKS, True): + setup_quirks( + custom_quirks_path=zha_data.yaml_config.get(CONF_CUSTOM_QUIRKS_PATH) + ) # temporary code to remove the ZHA storage file from disk. # this will be removed in 2022.10.0 @@ -134,7 +132,41 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b else: _LOGGER.debug("ZHA storage file does not exist or was already removed") - zha_gateway = ZHAGateway(hass, config, config_entry) + # Load and cache device trigger information early + device_registry = dr.async_get(hass) + radio_mgr = ZhaRadioManager.from_config_entry(hass, config_entry) + + async with radio_mgr.connect_zigpy_app() as app: + for dev in app.devices.values(): + dev_entry = device_registry.async_get_device( + identifiers={(DOMAIN, str(dev.ieee))}, + connections={(dr.CONNECTION_ZIGBEE, str(dev.ieee))}, + ) + + if dev_entry is None: + continue + + zha_data.device_trigger_cache[dev_entry.id] = ( + str(dev.ieee), + get_device_automation_triggers(dev), + ) + + _LOGGER.debug("Trigger cache: %s", zha_data.device_trigger_cache) + + zha_gateway = ZHAGateway(hass, zha_data.yaml_config, config_entry) + + async def async_zha_shutdown(): + """Handle shutdown tasks.""" + await zha_gateway.shutdown() + # clean up any remaining entity metadata + # (entities that have been discovered but not yet added to HA) + # suppress KeyError because we don't know what state we may + # be in when we get here in failure cases + with contextlib.suppress(KeyError): + for platform in PLATFORMS: + del zha_data.platforms[platform] + + config_entry.async_on_unload(async_zha_shutdown) try: await zha_gateway.async_initialize() @@ -153,9 +185,6 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b repairs.async_delete_blocking_issues(hass) - config_entry.async_on_unload(zha_gateway.shutdown) - - device_registry = dr.async_get(hass) device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_ZIGBEE, str(zha_gateway.coordinator_ieee))}, @@ -175,10 +204,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Unload ZHA config entry.""" - try: - del hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - except KeyError: - return False + zha_data = get_zha_data(hass) + zha_data.gateway = None GROUP_PROBE.cleanup() websocket_api.async_unload_api(hass) @@ -204,7 +231,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> CONF_DEVICE: {CONF_DEVICE_PATH: config_entry.data[CONF_USB_PATH]}, } - baudrate = hass.data[DATA_ZHA].get(DATA_ZHA_CONFIG, {}).get(CONF_BAUDRATE) + baudrate = get_zha_data(hass).yaml_config.get(CONF_BAUDRATE) if data[CONF_RADIO_TYPE] != RadioType.deconz and baudrate in BAUD_RATES: data[CONF_DEVICE][CONF_BAUDRATE] = baudrate diff --git a/homeassistant/components/zha/alarm_control_panel.py b/homeassistant/components/zha/alarm_control_panel.py index b6794e909d8c2b..21cacfa5dd42bf 100644 --- a/homeassistant/components/zha/alarm_control_panel.py +++ b/homeassistant/components/zha/alarm_control_panel.py @@ -35,11 +35,10 @@ CONF_ALARM_ARM_REQUIRES_CODE, CONF_ALARM_FAILED_TRIES, CONF_ALARM_MASTER_CODE, - DATA_ZHA, SIGNAL_ADD_ENTITIES, ZHA_ALARM_OPTIONS, ) -from .core.helpers import async_get_zha_config_value +from .core.helpers import async_get_zha_config_value, get_zha_data from .core.registries import ZHA_ENTITIES from .entity import ZhaEntity @@ -65,7 +64,8 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Zigbee Home Automation alarm control panel from config entry.""" - entities_to_create = hass.data[DATA_ZHA][Platform.ALARM_CONTROL_PANEL] + zha_data = get_zha_data(hass) + entities_to_create = zha_data.platforms[Platform.ALARM_CONTROL_PANEL] unsub = async_dispatcher_connect( hass, diff --git a/homeassistant/components/zha/api.py b/homeassistant/components/zha/api.py index 3d44103e225ead..f63fb9d09de78a 100644 --- a/homeassistant/components/zha/api.py +++ b/homeassistant/components/zha/api.py @@ -9,33 +9,22 @@ from zigpy.types import Channels from zigpy.util import pick_optimal_channel -from .core.const import ( - CONF_RADIO_TYPE, - DATA_ZHA, - DATA_ZHA_CONFIG, - DATA_ZHA_GATEWAY, - DOMAIN, - RadioType, -) +from .core.const import CONF_RADIO_TYPE, DOMAIN, RadioType from .core.gateway import ZHAGateway +from .core.helpers import get_zha_data, get_zha_gateway if TYPE_CHECKING: from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -def _get_gateway(hass: HomeAssistant) -> ZHAGateway: - """Get a reference to the ZHA gateway device.""" - return hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - - def _get_config_entry(hass: HomeAssistant) -> ConfigEntry: """Find the singleton ZHA config entry, if one exists.""" # If ZHA is already running, use its config entry try: - zha_gateway = _get_gateway(hass) - except KeyError: + zha_gateway = get_zha_gateway(hass) + except ValueError: pass else: return zha_gateway.config_entry @@ -51,8 +40,7 @@ def _get_config_entry(hass: HomeAssistant) -> ConfigEntry: def async_get_active_network_settings(hass: HomeAssistant) -> NetworkBackup: """Get the network settings for the currently active ZHA network.""" - zha_gateway: ZHAGateway = _get_gateway(hass) - app = zha_gateway.application_controller + app = get_zha_gateway(hass).application_controller return NetworkBackup( node_info=app.state.node_info, @@ -67,7 +55,7 @@ async def async_get_last_network_settings( if config_entry is None: config_entry = _get_config_entry(hass) - config = hass.data.get(DATA_ZHA, {}).get(DATA_ZHA_CONFIG, {}) + config = get_zha_data(hass).yaml_config zha_gateway = ZHAGateway(hass, config, config_entry) app_controller_cls, app_config = zha_gateway.get_application_controller_data() @@ -91,7 +79,7 @@ async def async_get_network_settings( try: return async_get_active_network_settings(hass) - except KeyError: + except ValueError: return await async_get_last_network_settings(hass, config_entry) @@ -120,8 +108,7 @@ async def async_change_channel( ) -> None: """Migrate the ZHA network to a new channel.""" - zha_gateway: ZHAGateway = _get_gateway(hass) - app = zha_gateway.application_controller + app = get_zha_gateway(hass).application_controller if new_channel == "auto": channel_energy = await app.energy_scan( diff --git a/homeassistant/components/zha/backup.py b/homeassistant/components/zha/backup.py index 89d5294e1c475b..e125a8085f6d1a 100644 --- a/homeassistant/components/zha/backup.py +++ b/homeassistant/components/zha/backup.py @@ -3,8 +3,7 @@ from homeassistant.core import HomeAssistant -from .core import ZHAGateway -from .core.const import DATA_ZHA, DATA_ZHA_GATEWAY +from .core.helpers import get_zha_gateway _LOGGER = logging.getLogger(__name__) @@ -13,7 +12,7 @@ async def async_pre_backup(hass: HomeAssistant) -> None: """Perform operations before a backup starts.""" _LOGGER.debug("Performing coordinator backup") - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) await zha_gateway.application_controller.backups.create_backup(load_devices=True) diff --git a/homeassistant/components/zha/binary_sensor.py b/homeassistant/components/zha/binary_sensor.py index 50cfb7833702e4..c32bd5eeb67a74 100644 --- a/homeassistant/components/zha/binary_sensor.py +++ b/homeassistant/components/zha/binary_sensor.py @@ -26,10 +26,10 @@ CLUSTER_HANDLER_OCCUPANCY, CLUSTER_HANDLER_ON_OFF, CLUSTER_HANDLER_ZONE, - DATA_ZHA, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, ) +from .core.helpers import get_zha_data from .core.registries import ZHA_ENTITIES from .entity import ZhaEntity @@ -65,7 +65,8 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Zigbee Home Automation binary sensor from config entry.""" - entities_to_create = hass.data[DATA_ZHA][Platform.BINARY_SENSOR] + zha_data = get_zha_data(hass) + entities_to_create = zha_data.platforms[Platform.BINARY_SENSOR] unsub = async_dispatcher_connect( hass, diff --git a/homeassistant/components/zha/button.py b/homeassistant/components/zha/button.py index 7a4132115b81e3..4114a3dea7cae9 100644 --- a/homeassistant/components/zha/button.py +++ b/homeassistant/components/zha/button.py @@ -14,7 +14,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .core import discovery -from .core.const import CLUSTER_HANDLER_IDENTIFY, DATA_ZHA, SIGNAL_ADD_ENTITIES +from .core.const import CLUSTER_HANDLER_IDENTIFY, SIGNAL_ADD_ENTITIES +from .core.helpers import get_zha_data from .core.registries import ZHA_ENTITIES from .entity import ZhaEntity @@ -38,7 +39,8 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Zigbee Home Automation button from config entry.""" - entities_to_create = hass.data[DATA_ZHA][Platform.BUTTON] + zha_data = get_zha_data(hass) + entities_to_create = zha_data.platforms[Platform.BUTTON] unsub = async_dispatcher_connect( hass, diff --git a/homeassistant/components/zha/climate.py b/homeassistant/components/zha/climate.py index cf868ef8b7b888..5cbe2684ab418d 100644 --- a/homeassistant/components/zha/climate.py +++ b/homeassistant/components/zha/climate.py @@ -45,13 +45,13 @@ from .core.const import ( CLUSTER_HANDLER_FAN, CLUSTER_HANDLER_THERMOSTAT, - DATA_ZHA, PRESET_COMPLEX, PRESET_SCHEDULE, PRESET_TEMP_MANUAL, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, ) +from .core.helpers import get_zha_data from .core.registries import ZHA_ENTITIES from .entity import ZhaEntity @@ -115,7 +115,8 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Zigbee Home Automation sensor from config entry.""" - entities_to_create = hass.data[DATA_ZHA][Platform.CLIMATE] + zha_data = get_zha_data(hass) + entities_to_create = zha_data.platforms[Platform.CLIMATE] unsub = async_dispatcher_connect( hass, SIGNAL_ADD_ENTITIES, diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index 63b59e9d8d42f8..b37fa7ffe6db92 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -184,8 +184,8 @@ DATA_DEVICE_CONFIG = "zha_device_config" DATA_ZHA = "zha" DATA_ZHA_CONFIG = "config" -DATA_ZHA_BRIDGE_ID = "zha_bridge_id" DATA_ZHA_CORE_EVENTS = "zha_core_events" +DATA_ZHA_DEVICE_TRIGGER_CACHE = "zha_device_trigger_cache" DATA_ZHA_GATEWAY = "zha_gateway" DEBUG_COMP_BELLOWS = "bellows" diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py index 1455173b27c217..8f5b087f068af5 100644 --- a/homeassistant/components/zha/core/device.py +++ b/homeassistant/components/zha/core/device.py @@ -25,6 +25,7 @@ from homeassistant.const import ATTR_COMMAND, ATTR_DEVICE_ID, ATTR_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, @@ -93,6 +94,16 @@ _CHECKIN_GRACE_PERIODS = 2 +def get_device_automation_triggers( + device: zigpy.device.Device, +) -> dict[tuple[str, str], dict[str, str]]: + """Get the supported device automation triggers for a zigpy device.""" + return { + ("device_offline", "device_offline"): {"device_event_type": "device_offline"}, + **getattr(device, "device_automation_triggers", {}), + } + + class DeviceStatus(Enum): """Status of a device.""" @@ -311,16 +322,7 @@ def device_automation_commands(self) -> dict[str, list[tuple[str, str]]]: @cached_property def device_automation_triggers(self) -> dict[tuple[str, str], dict[str, str]]: """Return the device automation triggers for this device.""" - triggers = { - ("device_offline", "device_offline"): { - "device_event_type": "device_offline" - } - } - - if hasattr(self._zigpy_device, "device_automation_triggers"): - triggers.update(self._zigpy_device.device_automation_triggers) - - return triggers + return get_device_automation_triggers(self._zigpy_device) @property def available_signal(self) -> str: @@ -419,7 +421,9 @@ def async_update_sw_build_id(self, sw_version: int) -> None: """Update device sw version.""" if self.device_id is None: return - self._zha_gateway.ha_device_registry.async_update_device( + + device_registry = dr.async_get(self.hass) + device_registry.async_update_device( self.device_id, sw_version=f"0x{sw_version:08x}" ) @@ -657,7 +661,8 @@ def zha_device_info(self) -> dict[str, Any]: ) device_info[ATTR_ENDPOINT_NAMES] = names - reg_device = self.gateway.ha_device_registry.async_get(self.device_id) + device_registry = dr.async_get(self.hass) + reg_device = device_registry.async_get(self.device_id) if reg_device is not None: device_info["user_given_name"] = reg_device.name_by_user device_info["device_reg_id"] = reg_device.id diff --git a/homeassistant/components/zha/core/discovery.py b/homeassistant/components/zha/core/discovery.py index 92b68bdb159218..a56e7044d3a61c 100644 --- a/homeassistant/components/zha/core/discovery.py +++ b/homeassistant/components/zha/core/discovery.py @@ -4,10 +4,11 @@ from collections import Counter from collections.abc import Callable import logging -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast from homeassistant.const import CONF_TYPE, Platform from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, @@ -49,12 +50,12 @@ security, smartenergy, ) +from .helpers import get_zha_data, get_zha_gateway if TYPE_CHECKING: from ..entity import ZhaEntity from .device import ZHADevice from .endpoint import Endpoint - from .gateway import ZHAGateway from .group import ZHAGroup _LOGGER = logging.getLogger(__name__) @@ -113,6 +114,8 @@ def discover_by_device_type(self, endpoint: Endpoint) -> None: platform = zha_regs.DEVICE_CLASS[ep_profile_id].get(ep_device_type) if platform and platform in zha_const.PLATFORMS: + platform = cast(Platform, platform) + cluster_handlers = endpoint.unclaimed_cluster_handlers() platform_entity_class, claimed = zha_regs.ZHA_ENTITIES.get_entity( platform, @@ -263,9 +266,7 @@ def discover_multi_entities( def initialize(self, hass: HomeAssistant) -> None: """Update device overrides config.""" - zha_config: ConfigType = hass.data[zha_const.DATA_ZHA].get( - zha_const.DATA_ZHA_CONFIG, {} - ) + zha_config = get_zha_data(hass).yaml_config if overrides := zha_config.get(zha_const.CONF_DEVICE_CONFIG): self._device_configs.update(overrides) @@ -297,9 +298,7 @@ def cleanup(self) -> None: @callback def _reprobe_group(self, group_id: int) -> None: """Reprobe a group for entities after its members change.""" - zha_gateway: ZHAGateway = self._hass.data[zha_const.DATA_ZHA][ - zha_const.DATA_ZHA_GATEWAY - ] + zha_gateway = get_zha_gateway(self._hass) if (zha_group := zha_gateway.groups.get(group_id)) is None: return self.discover_group_entities(zha_group) @@ -321,14 +320,14 @@ def discover_group_entities(self, group: ZHAGroup) -> None: if not entity_domains: return - zha_gateway: ZHAGateway = self._hass.data[zha_const.DATA_ZHA][ - zha_const.DATA_ZHA_GATEWAY - ] + zha_data = get_zha_data(self._hass) + zha_gateway = get_zha_gateway(self._hass) + for domain in entity_domains: entity_class = zha_regs.ZHA_ENTITIES.get_group_entity(domain) if entity_class is None: continue - self._hass.data[zha_const.DATA_ZHA][domain].append( + zha_data.platforms[domain].append( ( entity_class, ( @@ -342,24 +341,26 @@ def discover_group_entities(self, group: ZHAGroup) -> None: async_dispatcher_send(self._hass, zha_const.SIGNAL_ADD_ENTITIES) @staticmethod - def determine_entity_domains(hass: HomeAssistant, group: ZHAGroup) -> list[str]: + def determine_entity_domains( + hass: HomeAssistant, group: ZHAGroup + ) -> list[Platform]: """Determine the entity domains for this group.""" - entity_domains: list[str] = [] - zha_gateway: ZHAGateway = hass.data[zha_const.DATA_ZHA][ - zha_const.DATA_ZHA_GATEWAY - ] - all_domain_occurrences = [] + entity_registry = er.async_get(hass) + + entity_domains: list[Platform] = [] + all_domain_occurrences: list[Platform] = [] + for member in group.members: if member.device.is_coordinator: continue entities = async_entries_for_device( - zha_gateway.ha_entity_registry, + entity_registry, member.device.device_id, include_disabled_entities=True, ) all_domain_occurrences.extend( [ - entity.domain + cast(Platform, entity.domain) for entity in entities if entity.domain in zha_regs.GROUP_ENTITY_DOMAINS ] diff --git a/homeassistant/components/zha/core/endpoint.py b/homeassistant/components/zha/core/endpoint.py index bdef5ac46af7ed..c87ee60d6b30d6 100644 --- a/homeassistant/components/zha/core/endpoint.py +++ b/homeassistant/components/zha/core/endpoint.py @@ -16,6 +16,7 @@ from . import const, discovery, registries from .cluster_handlers import ClusterHandler from .cluster_handlers.general import MultistateInput +from .helpers import get_zha_data if TYPE_CHECKING: from .cluster_handlers import ClientClusterHandler @@ -195,7 +196,7 @@ async def _execute_handler_tasks(self, func_name: str, *args: Any) -> None: def async_new_entity( self, - platform: Platform | str, + platform: Platform, entity_class: CALLABLE_T, unique_id: str, cluster_handlers: list[ClusterHandler], @@ -206,7 +207,8 @@ def async_new_entity( if self.device.status == DeviceStatus.INITIALIZED: return - self.device.hass.data[const.DATA_ZHA][platform].append( + zha_data = get_zha_data(self.device.hass) + zha_data.platforms[platform].append( (entity_class, (unique_id, self.device, cluster_handlers)) ) diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index 3abf1274f984e5..c5d04dda9611e8 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -10,7 +10,6 @@ import logging import re import time -import traceback from typing import TYPE_CHECKING, Any, NamedTuple from zigpy.application import ControllerApplication @@ -46,9 +45,6 @@ CONF_RADIO_TYPE, CONF_USE_THREAD, CONF_ZIGPY, - DATA_ZHA, - DATA_ZHA_BRIDGE_ID, - DATA_ZHA_GATEWAY, DEBUG_COMP_BELLOWS, DEBUG_COMP_ZHA, DEBUG_COMP_ZIGPY, @@ -87,6 +83,7 @@ ) from .device import DeviceStatus, ZHADevice from .group import GroupMember, ZHAGroup +from .helpers import get_zha_data from .registries import GROUP_ENTITY_DOMAINS if TYPE_CHECKING: @@ -123,8 +120,6 @@ class ZHAGateway: """Gateway that handles events that happen on the ZHA Zigbee network.""" # -- Set in async_initialize -- - ha_device_registry: dr.DeviceRegistry - ha_entity_registry: er.EntityRegistry application_controller: ControllerApplication radio_description: str @@ -132,7 +127,7 @@ def __init__( self, hass: HomeAssistant, config: ConfigType, config_entry: ConfigEntry ) -> None: """Initialize the gateway.""" - self._hass = hass + self.hass = hass self._config = config self._devices: dict[EUI64, ZHADevice] = {} self._groups: dict[int, ZHAGroup] = {} @@ -159,7 +154,7 @@ def get_application_controller_data(self) -> tuple[ControllerApplication, dict]: app_config = self._config.get(CONF_ZIGPY, {}) database = self._config.get( CONF_DATABASE, - self._hass.config.path(DEFAULT_DATABASE_NAME), + self.hass.config.path(DEFAULT_DATABASE_NAME), ) app_config[CONF_DATABASE] = database app_config[CONF_DEVICE] = self.config_entry.data[CONF_DEVICE] @@ -191,11 +186,8 @@ def get_application_controller_data(self) -> tuple[ControllerApplication, dict]: async def async_initialize(self) -> None: """Initialize controller and connect radio.""" - discovery.PROBE.initialize(self._hass) - discovery.GROUP_PROBE.initialize(self._hass) - - self.ha_device_registry = dr.async_get(self._hass) - self.ha_entity_registry = er.async_get(self._hass) + discovery.PROBE.initialize(self.hass) + discovery.GROUP_PROBE.initialize(self.hass) app_controller_cls, app_config = self.get_application_controller_data() self.application_controller = await app_controller_cls.new( @@ -204,23 +196,6 @@ async def async_initialize(self) -> None: start_radio=False, ) - self._hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] = self - - self.async_load_devices() - - # Groups are attached to the coordinator device so we need to load it early - coordinator = self._find_coordinator_device() - loaded_groups = False - - # We can only load groups early if the coordinator's model info has been stored - # in the zigpy database - if coordinator.model is not None: - self.coordinator_zha_device = self._async_get_or_create_device( - coordinator, restored=True - ) - self.async_load_groups() - loaded_groups = True - for attempt in range(STARTUP_RETRIES): try: await self.application_controller.startup(auto_form=True) @@ -242,14 +217,15 @@ async def async_initialize(self) -> None: else: break + zha_data = get_zha_data(self.hass) + zha_data.gateway = self + self.coordinator_zha_device = self._async_get_or_create_device( self._find_coordinator_device(), restored=True ) - self._hass.data[DATA_ZHA][DATA_ZHA_BRIDGE_ID] = str(self.coordinator_ieee) - # If ZHA groups could not load early, we can safely load them now - if not loaded_groups: - self.async_load_groups() + self.async_load_devices() + self.async_load_groups() self.application_controller.add_listener(self) self.application_controller.groups.add_listener(self) @@ -317,7 +293,7 @@ async def fetch_updated_state() -> None: # background the fetching of state for mains powered devices self.config_entry.async_create_background_task( - self._hass, fetch_updated_state(), "zha.gateway-fetch_updated_state" + self.hass, fetch_updated_state(), "zha.gateway-fetch_updated_state" ) def device_joined(self, device: zigpy.device.Device) -> None: @@ -327,7 +303,7 @@ def device_joined(self, device: zigpy.device.Device) -> None: address """ async_dispatcher_send( - self._hass, + self.hass, ZHA_GW_MSG, { ATTR_TYPE: ZHA_GW_MSG_DEVICE_JOINED, @@ -343,7 +319,7 @@ def raw_device_initialized(self, device: zigpy.device.Device) -> None: """Handle a device initialization without quirks loaded.""" manuf = device.manufacturer async_dispatcher_send( - self._hass, + self.hass, ZHA_GW_MSG, { ATTR_TYPE: ZHA_GW_MSG_RAW_INIT, @@ -360,7 +336,7 @@ def raw_device_initialized(self, device: zigpy.device.Device) -> None: def device_initialized(self, device: zigpy.device.Device) -> None: """Handle device joined and basic information discovered.""" - self._hass.async_create_task(self.async_device_initialized(device)) + self.hass.async_create_task(self.async_device_initialized(device)) def device_left(self, device: zigpy.device.Device) -> None: """Handle device leaving the network.""" @@ -375,7 +351,7 @@ def group_member_removed( zha_group.info("group_member_removed - endpoint: %s", endpoint) self._send_group_gateway_message(zigpy_group, ZHA_GW_MSG_GROUP_MEMBER_REMOVED) async_dispatcher_send( - self._hass, f"{SIGNAL_GROUP_MEMBERSHIP_CHANGE}_0x{zigpy_group.group_id:04x}" + self.hass, f"{SIGNAL_GROUP_MEMBERSHIP_CHANGE}_0x{zigpy_group.group_id:04x}" ) def group_member_added( @@ -387,7 +363,7 @@ def group_member_added( zha_group.info("group_member_added - endpoint: %s", endpoint) self._send_group_gateway_message(zigpy_group, ZHA_GW_MSG_GROUP_MEMBER_ADDED) async_dispatcher_send( - self._hass, f"{SIGNAL_GROUP_MEMBERSHIP_CHANGE}_0x{zigpy_group.group_id:04x}" + self.hass, f"{SIGNAL_GROUP_MEMBERSHIP_CHANGE}_0x{zigpy_group.group_id:04x}" ) if len(zha_group.members) == 2: # we need to do this because there wasn't already @@ -415,7 +391,7 @@ def _send_group_gateway_message( zha_group = self._groups.get(zigpy_group.group_id) if zha_group is not None: async_dispatcher_send( - self._hass, + self.hass, ZHA_GW_MSG, { ATTR_TYPE: gateway_message_type, @@ -432,9 +408,11 @@ async def _async_remove_device( remove_tasks.append(entity_ref.remove_future) if remove_tasks: await asyncio.wait(remove_tasks) - reg_device = self.ha_device_registry.async_get(device.device_id) + + device_registry = dr.async_get(self.hass) + reg_device = device_registry.async_get(device.device_id) if reg_device is not None: - self.ha_device_registry.async_remove_device(reg_device.id) + device_registry.async_remove_device(reg_device.id) def device_removed(self, device: zigpy.device.Device) -> None: """Handle device being removed from the network.""" @@ -443,14 +421,14 @@ def device_removed(self, device: zigpy.device.Device) -> None: if zha_device is not None: device_info = zha_device.zha_device_info zha_device.async_cleanup_handles() - async_dispatcher_send(self._hass, f"{SIGNAL_REMOVE}_{str(zha_device.ieee)}") - self._hass.async_create_task( + async_dispatcher_send(self.hass, f"{SIGNAL_REMOVE}_{str(zha_device.ieee)}") + self.hass.async_create_task( self._async_remove_device(zha_device, entity_refs), "ZHAGateway._async_remove_device", ) if device_info is not None: async_dispatcher_send( - self._hass, + self.hass, ZHA_GW_MSG, { ATTR_TYPE: ZHA_GW_MSG_DEVICE_REMOVED, @@ -504,9 +482,10 @@ def _cleanup_group_entity_registry_entries( ] # then we get all group entity entries tied to the coordinator + entity_registry = er.async_get(self.hass) assert self.coordinator_zha_device all_group_entity_entries = er.async_entries_for_device( - self.ha_entity_registry, + entity_registry, self.coordinator_zha_device.device_id, include_disabled_entities=True, ) @@ -524,7 +503,7 @@ def _cleanup_group_entity_registry_entries( _LOGGER.debug( "cleaning up entity registry entry for entity: %s", entry.entity_id ) - self.ha_entity_registry.async_remove(entry.entity_id) + entity_registry.async_remove(entry.entity_id) @property def coordinator_ieee(self) -> EUI64: @@ -598,9 +577,11 @@ def _async_get_or_create_device( ) -> ZHADevice: """Get or create a ZHA device.""" if (zha_device := self._devices.get(zigpy_device.ieee)) is None: - zha_device = ZHADevice.new(self._hass, zigpy_device, self, restored) + zha_device = ZHADevice.new(self.hass, zigpy_device, self, restored) self._devices[zigpy_device.ieee] = zha_device - device_registry_device = self.ha_device_registry.async_get_or_create( + + device_registry = dr.async_get(self.hass) + device_registry_device = device_registry.async_get_or_create( config_entry_id=self.config_entry.entry_id, connections={(dr.CONNECTION_ZIGBEE, str(zha_device.ieee))}, identifiers={(DOMAIN, str(zha_device.ieee))}, @@ -616,7 +597,7 @@ def _async_get_or_create_group(self, zigpy_group: zigpy.group.Group) -> ZHAGroup """Get or create a ZHA group.""" zha_group = self._groups.get(zigpy_group.group_id) if zha_group is None: - zha_group = ZHAGroup(self._hass, self, zigpy_group) + zha_group = ZHAGroup(self.hass, self, zigpy_group) self._groups[zigpy_group.group_id] = zha_group return zha_group @@ -661,7 +642,7 @@ async def async_device_initialized(self, device: zigpy.device.Device) -> None: device_info = zha_device.zha_device_info device_info[DEVICE_PAIRING_STATUS] = DevicePairingStatus.INITIALIZED.name async_dispatcher_send( - self._hass, + self.hass, ZHA_GW_MSG, { ATTR_TYPE: ZHA_GW_MSG_DEVICE_FULL_INIT, @@ -675,7 +656,7 @@ async def _async_device_joined(self, zha_device: ZHADevice) -> None: await zha_device.async_configure() device_info[DEVICE_PAIRING_STATUS] = DevicePairingStatus.CONFIGURED.name async_dispatcher_send( - self._hass, + self.hass, ZHA_GW_MSG, { ATTR_TYPE: ZHA_GW_MSG_DEVICE_FULL_INIT, @@ -683,7 +664,7 @@ async def _async_device_joined(self, zha_device: ZHADevice) -> None: }, ) await zha_device.async_initialize(from_cache=False) - async_dispatcher_send(self._hass, SIGNAL_ADD_ENTITIES) + async_dispatcher_send(self.hass, SIGNAL_ADD_ENTITIES) async def _async_device_rejoined(self, zha_device: ZHADevice) -> None: _LOGGER.debug( @@ -697,7 +678,7 @@ async def _async_device_rejoined(self, zha_device: ZHADevice) -> None: device_info = zha_device.device_info device_info[DEVICE_PAIRING_STATUS] = DevicePairingStatus.CONFIGURED.name async_dispatcher_send( - self._hass, + self.hass, ZHA_GW_MSG, { ATTR_TYPE: ZHA_GW_MSG_DEVICE_FULL_INIT, @@ -766,7 +747,15 @@ async def shutdown(self) -> None: unsubscribe() for device in self.devices.values(): device.async_cleanup_handles() - await self.application_controller.shutdown() + # shutdown is called when the config entry unloads are processed + # there are cases where unloads are processed because of a failure of + # some sort and the application controller may not have been + # created yet + if ( + hasattr(self, "application_controller") + and self.application_controller is not None + ): + await self.application_controller.shutdown() def handle_message( self, @@ -824,21 +813,20 @@ def __init__(self, hass: HomeAssistant, gateway: ZHAGateway) -> None: super().__init__() self.hass = hass self.gateway = gateway - - def emit(self, record: LogRecord) -> None: - """Relay log message via dispatcher.""" - stack = [] - if record.levelno >= logging.WARN and not record.exc_info: - stack = [f for f, _, _, _ in traceback.extract_stack()] - hass_path: str = HOMEASSISTANT_PATH[0] config_dir = self.hass.config.config_dir - paths_re = re.compile( + self.paths_re = re.compile( r"(?:{})/(.*)".format( "|".join([re.escape(x) for x in (hass_path, config_dir)]) ) ) - entry = LogEntry(record, _figure_out_source(record, stack, paths_re)) + + def emit(self, record: LogRecord) -> None: + """Relay log message via dispatcher.""" + if record.levelno >= logging.WARN: + entry = LogEntry(record, _figure_out_source(record, self.paths_re)) + else: + entry = LogEntry(record, (record.pathname, record.lineno)) async_dispatcher_send( self.hass, ZHA_GW_MSG, diff --git a/homeassistant/components/zha/core/group.py b/homeassistant/components/zha/core/group.py index ebea2f4ac4123d..519668052e0ede 100644 --- a/homeassistant/components/zha/core/group.py +++ b/homeassistant/components/zha/core/group.py @@ -11,6 +11,7 @@ from zigpy.types.named import EUI64 from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_registry import async_entries_for_device from .helpers import LogMixin @@ -32,8 +33,8 @@ class GroupMember(NamedTuple): class GroupEntityReference(NamedTuple): """Reference to a group entity.""" - name: str - original_name: str + name: str | None + original_name: str | None entity_id: int @@ -80,20 +81,30 @@ def member_info(self) -> dict[str, Any]: @property def associated_entities(self) -> list[dict[str, Any]]: """Return the list of entities that were derived from this endpoint.""" - ha_entity_registry = self.device.gateway.ha_entity_registry + entity_registry = er.async_get(self._zha_device.hass) zha_device_registry = self.device.gateway.device_registry - return [ - GroupEntityReference( - ha_entity_registry.async_get(entity_ref.reference_id).name, - ha_entity_registry.async_get(entity_ref.reference_id).original_name, - entity_ref.reference_id, - )._asdict() - for entity_ref in zha_device_registry.get(self.device.ieee) - if list(entity_ref.cluster_handlers.values())[ - 0 - ].cluster.endpoint.endpoint_id - == self.endpoint_id - ] + + entity_info = [] + + for entity_ref in zha_device_registry.get(self.device.ieee): + entity = entity_registry.async_get(entity_ref.reference_id) + handler = list(entity_ref.cluster_handlers.values())[0] + + if ( + entity is None + or handler.cluster.endpoint.endpoint_id != self.endpoint_id + ): + continue + + entity_info.append( + GroupEntityReference( + name=entity.name, + original_name=entity.original_name, + entity_id=entity_ref.reference_id, + )._asdict() + ) + + return entity_info async def async_remove_from_group(self) -> None: """Remove the device endpoint from the provided zigbee group.""" @@ -204,12 +215,14 @@ def member_entity_ids(self) -> list[str]: def get_domain_entity_ids(self, domain: str) -> list[str]: """Return entity ids from the entity domain for this group.""" + entity_registry = er.async_get(self.hass) domain_entity_ids: list[str] = [] + for member in self.members: if member.device.is_coordinator: continue entities = async_entries_for_device( - self._zha_gateway.ha_entity_registry, + entity_registry, member.device.device_id, include_disabled_entities=True, ) diff --git a/homeassistant/components/zha/core/helpers.py b/homeassistant/components/zha/core/helpers.py index 7b0d062738b4ae..4df546b449c279 100644 --- a/homeassistant/components/zha/core/helpers.py +++ b/homeassistant/components/zha/core/helpers.py @@ -7,7 +7,9 @@ import asyncio import binascii +import collections from collections.abc import Callable, Iterator +import dataclasses from dataclasses import dataclass import enum import functools @@ -26,16 +28,12 @@ import zigpy.zdo.types as zdo_types from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.helpers.typing import ConfigType -from .const import ( - CLUSTER_TYPE_IN, - CLUSTER_TYPE_OUT, - CUSTOM_CONFIGURATION, - DATA_ZHA, - DATA_ZHA_GATEWAY, -) +from .const import CLUSTER_TYPE_IN, CLUSTER_TYPE_OUT, CUSTOM_CONFIGURATION, DATA_ZHA from .registries import BINDABLE_CLUSTERS if TYPE_CHECKING: @@ -221,7 +219,7 @@ def async_get_zha_config_value( def async_cluster_exists(hass, cluster_id, skip_coordinator=True): """Determine if a device containing the specified in cluster is paired.""" - zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) zha_devices = zha_gateway.devices.values() for zha_device in zha_devices: if skip_coordinator and zha_device.is_coordinator: @@ -244,7 +242,7 @@ def async_get_zha_device(hass: HomeAssistant, device_id: str) -> ZHADevice: if not registry_device: _LOGGER.error("Device id `%s` not found in registry", device_id) raise KeyError(f"Device id `{device_id}` not found in registry.") - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) try: ieee_address = list(registry_device.identifiers)[0][1] ieee = zigpy.types.EUI64.convert(ieee_address) @@ -421,3 +419,30 @@ def qr_to_install_code(qr_code: str) -> tuple[zigpy.types.EUI64, bytes]: return ieee, install_code raise vol.Invalid(f"couldn't convert qr code: {qr_code}") + + +@dataclasses.dataclass(kw_only=True, slots=True) +class ZHAData: + """ZHA component data stored in `hass.data`.""" + + yaml_config: ConfigType = dataclasses.field(default_factory=dict) + platforms: collections.defaultdict[Platform, list] = dataclasses.field( + default_factory=lambda: collections.defaultdict(list) + ) + gateway: ZHAGateway | None = dataclasses.field(default=None) + device_trigger_cache: dict[str, tuple[str, dict]] = dataclasses.field( + default_factory=dict + ) + + +def get_zha_data(hass: HomeAssistant) -> ZHAData: + """Get the global ZHA data object.""" + return hass.data.get(DATA_ZHA, ZHAData()) + + +def get_zha_gateway(hass: HomeAssistant) -> ZHAGateway: + """Get the ZHA gateway object.""" + if (zha_gateway := get_zha_data(hass).gateway) is None: + raise ValueError("No gateway object exists") + + return zha_gateway diff --git a/homeassistant/components/zha/core/registries.py b/homeassistant/components/zha/core/registries.py index 03fdc7e37c1f0e..74f724bdc493dd 100644 --- a/homeassistant/components/zha/core/registries.py +++ b/homeassistant/components/zha/core/registries.py @@ -4,6 +4,7 @@ import collections from collections.abc import Callable import dataclasses +from operator import attrgetter from typing import TYPE_CHECKING, TypeVar import attr @@ -111,6 +112,8 @@ ] = DictRegistry() ZIGBEE_CLUSTER_HANDLER_REGISTRY: DictRegistry[type[ClusterHandler]] = DictRegistry() +WEIGHT_ATTR = attrgetter("weight") + def set_or_callable(value) -> frozenset[str] | Callable: """Convert single str or None to a set. Pass through callables and sets.""" @@ -266,15 +269,15 @@ class ZHAEntityRegistry: def __init__(self) -> None: """Initialize Registry instance.""" self._strict_registry: dict[ - str, dict[MatchRule, type[ZhaEntity]] + Platform, dict[MatchRule, type[ZhaEntity]] ] = collections.defaultdict(dict) self._multi_entity_registry: dict[ - str, dict[int | str | None, dict[MatchRule, list[type[ZhaEntity]]]] + Platform, dict[int | str | None, dict[MatchRule, list[type[ZhaEntity]]]] ] = collections.defaultdict( lambda: collections.defaultdict(lambda: collections.defaultdict(list)) ) self._config_diagnostic_entity_registry: dict[ - str, dict[int | str | None, dict[MatchRule, list[type[ZhaEntity]]]] + Platform, dict[int | str | None, dict[MatchRule, list[type[ZhaEntity]]]] ] = collections.defaultdict( lambda: collections.defaultdict(lambda: collections.defaultdict(list)) ) @@ -285,7 +288,7 @@ def __init__(self) -> None: def get_entity( self, - component: str, + component: Platform, manufacturer: str, model: str, cluster_handlers: list[ClusterHandler], @@ -294,7 +297,7 @@ def get_entity( ) -> tuple[type[ZhaEntity] | None, list[ClusterHandler]]: """Match a ZHA ClusterHandler to a ZHA Entity class.""" matches = self._strict_registry[component] - for match in sorted(matches, key=lambda x: x.weight, reverse=True): + for match in sorted(matches, key=WEIGHT_ATTR, reverse=True): if match.strict_matched(manufacturer, model, cluster_handlers, quirk_class): claimed = match.claim_cluster_handlers(cluster_handlers) return self._strict_registry[component][match], claimed @@ -307,15 +310,17 @@ def get_multi_entity( model: str, cluster_handlers: list[ClusterHandler], quirk_class: str, - ) -> tuple[dict[str, list[EntityClassAndClusterHandlers]], list[ClusterHandler]]: + ) -> tuple[ + dict[Platform, list[EntityClassAndClusterHandlers]], list[ClusterHandler] + ]: """Match ZHA cluster handlers to potentially multiple ZHA Entity classes.""" result: dict[ - str, list[EntityClassAndClusterHandlers] + Platform, list[EntityClassAndClusterHandlers] ] = collections.defaultdict(list) all_claimed: set[ClusterHandler] = set() for component, stop_match_groups in self._multi_entity_registry.items(): for stop_match_grp, matches in stop_match_groups.items(): - sorted_matches = sorted(matches, key=lambda x: x.weight, reverse=True) + sorted_matches = sorted(matches, key=WEIGHT_ATTR, reverse=True) for match in sorted_matches: if match.strict_matched( manufacturer, model, cluster_handlers, quirk_class @@ -338,10 +343,12 @@ def get_config_diagnostic_entity( model: str, cluster_handlers: list[ClusterHandler], quirk_class: str, - ) -> tuple[dict[str, list[EntityClassAndClusterHandlers]], list[ClusterHandler]]: + ) -> tuple[ + dict[Platform, list[EntityClassAndClusterHandlers]], list[ClusterHandler] + ]: """Match ZHA cluster handlers to potentially multiple ZHA Entity classes.""" result: dict[ - str, list[EntityClassAndClusterHandlers] + Platform, list[EntityClassAndClusterHandlers] ] = collections.defaultdict(list) all_claimed: set[ClusterHandler] = set() for ( @@ -349,7 +356,7 @@ def get_config_diagnostic_entity( stop_match_groups, ) in self._config_diagnostic_entity_registry.items(): for stop_match_grp, matches in stop_match_groups.items(): - sorted_matches = sorted(matches, key=lambda x: x.weight, reverse=True) + sorted_matches = sorted(matches, key=WEIGHT_ATTR, reverse=True) for match in sorted_matches: if match.strict_matched( manufacturer, model, cluster_handlers, quirk_class @@ -372,7 +379,7 @@ def get_group_entity(self, component: str) -> type[ZhaGroupEntity] | None: def strict_match( self, - component: str, + component: Platform, cluster_handler_names: set[str] | str | None = None, generic_ids: set[str] | str | None = None, manufacturers: Callable | set[str] | str | None = None, @@ -403,7 +410,7 @@ def decorator(zha_ent: _ZhaEntityT) -> _ZhaEntityT: def multipass_match( self, - component: str, + component: Platform, cluster_handler_names: set[str] | str | None = None, generic_ids: set[str] | str | None = None, manufacturers: Callable | set[str] | str | None = None, @@ -438,7 +445,7 @@ def decorator(zha_entity: _ZhaEntityT) -> _ZhaEntityT: def config_diagnostic_match( self, - component: str, + component: Platform, cluster_handler_names: set[str] | str | None = None, generic_ids: set[str] | str | None = None, manufacturers: Callable | set[str] | str | None = None, @@ -472,7 +479,7 @@ def decorator(zha_entity: _ZhaEntityT) -> _ZhaEntityT: return decorator def group_match( - self, component: str + self, component: Platform ) -> Callable[[_ZhaGroupEntityT], _ZhaGroupEntityT]: """Decorate a group match rule.""" diff --git a/homeassistant/components/zha/cover.py b/homeassistant/components/zha/cover.py index 0d7062173ca2f5..f2aed0390f3846 100644 --- a/homeassistant/components/zha/cover.py +++ b/homeassistant/components/zha/cover.py @@ -33,11 +33,11 @@ CLUSTER_HANDLER_LEVEL, CLUSTER_HANDLER_ON_OFF, CLUSTER_HANDLER_SHADE, - DATA_ZHA, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, SIGNAL_SET_LEVEL, ) +from .core.helpers import get_zha_data from .core.registries import ZHA_ENTITIES from .entity import ZhaEntity @@ -56,7 +56,8 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Zigbee Home Automation cover from config entry.""" - entities_to_create = hass.data[DATA_ZHA][Platform.COVER] + zha_data = get_zha_data(hass) + entities_to_create = zha_data.platforms[Platform.COVER] unsub = async_dispatcher_connect( hass, diff --git a/homeassistant/components/zha/device_tracker.py b/homeassistant/components/zha/device_tracker.py index bda346624dd690..ea27c58eb1926a 100644 --- a/homeassistant/components/zha/device_tracker.py +++ b/homeassistant/components/zha/device_tracker.py @@ -15,10 +15,10 @@ from .core import discovery from .core.const import ( CLUSTER_HANDLER_POWER_CONFIGURATION, - DATA_ZHA, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, ) +from .core.helpers import get_zha_data from .core.registries import ZHA_ENTITIES from .entity import ZhaEntity from .sensor import Battery @@ -32,7 +32,8 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Zigbee Home Automation device tracker from config entry.""" - entities_to_create = hass.data[DATA_ZHA][Platform.DEVICE_TRACKER] + zha_data = get_zha_data(hass) + entities_to_create = zha_data.platforms[Platform.DEVICE_TRACKER] unsub = async_dispatcher_connect( hass, diff --git a/homeassistant/components/zha/device_trigger.py b/homeassistant/components/zha/device_trigger.py index 9e33e3fa6159a7..a2ae734b8fc2cf 100644 --- a/homeassistant/components/zha/device_trigger.py +++ b/homeassistant/components/zha/device_trigger.py @@ -9,13 +9,13 @@ from homeassistant.components.homeassistant.triggers import event as event_trigger from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_PLATFORM, CONF_TYPE from homeassistant.core import CALLBACK_TYPE, HomeAssistant -from homeassistant.exceptions import HomeAssistantError, IntegrationError +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType from . import DOMAIN as ZHA_DOMAIN from .core.const import ZHA_EVENT -from .core.helpers import async_get_zha_device +from .core.helpers import async_get_zha_device, get_zha_data CONF_SUBTYPE = "subtype" DEVICE = "device" @@ -26,21 +26,32 @@ ) +def _get_device_trigger_data(hass: HomeAssistant, device_id: str) -> tuple[str, dict]: + """Get device trigger data for a device, falling back to the cache if possible.""" + + # First, try checking to see if the device itself is accessible + try: + zha_device = async_get_zha_device(hass, device_id) + except ValueError: + pass + else: + return str(zha_device.ieee), zha_device.device_automation_triggers + + # If not, check the trigger cache but allow any `KeyError`s to propagate + return get_zha_data(hass).device_trigger_cache[device_id] + + async def async_validate_trigger_config( hass: HomeAssistant, config: ConfigType ) -> ConfigType: """Validate config.""" config = TRIGGER_SCHEMA(config) + # Trigger validation will not occur if the config entry is not loaded + _, triggers = _get_device_trigger_data(hass, config[CONF_DEVICE_ID]) + trigger = (config[CONF_TYPE], config[CONF_SUBTYPE]) - try: - zha_device = async_get_zha_device(hass, config[CONF_DEVICE_ID]) - except (KeyError, AttributeError, IntegrationError) as err: - raise InvalidDeviceAutomationConfig from err - if ( - zha_device.device_automation_triggers is None - or trigger not in zha_device.device_automation_triggers - ): + if trigger not in triggers: raise InvalidDeviceAutomationConfig(f"device does not have trigger {trigger}") return config @@ -53,26 +64,26 @@ async def async_attach_trigger( trigger_info: TriggerInfo, ) -> CALLBACK_TYPE: """Listen for state changes based on configuration.""" - trigger_key: tuple[str, str] = (config[CONF_TYPE], config[CONF_SUBTYPE]) + try: - zha_device = async_get_zha_device(hass, config[CONF_DEVICE_ID]) - except (KeyError, AttributeError) as err: + ieee, triggers = _get_device_trigger_data(hass, config[CONF_DEVICE_ID]) + except KeyError as err: raise HomeAssistantError( f"Unable to get zha device {config[CONF_DEVICE_ID]}" ) from err - if trigger_key not in zha_device.device_automation_triggers: - raise HomeAssistantError(f"Unable to find trigger {trigger_key}") - - trigger = zha_device.device_automation_triggers[trigger_key] + trigger_key: tuple[str, str] = (config[CONF_TYPE], config[CONF_SUBTYPE]) - event_config = { - event_trigger.CONF_PLATFORM: "event", - event_trigger.CONF_EVENT_TYPE: ZHA_EVENT, - event_trigger.CONF_EVENT_DATA: {DEVICE_IEEE: str(zha_device.ieee), **trigger}, - } + if trigger_key not in triggers: + raise HomeAssistantError(f"Unable to find trigger {trigger_key}") - event_config = event_trigger.TRIGGER_SCHEMA(event_config) + event_config = event_trigger.TRIGGER_SCHEMA( + { + event_trigger.CONF_PLATFORM: "event", + event_trigger.CONF_EVENT_TYPE: ZHA_EVENT, + event_trigger.CONF_EVENT_DATA: {DEVICE_IEEE: ieee, **triggers[trigger_key]}, + } + ) return await event_trigger.async_attach_trigger( hass, event_config, action, trigger_info, platform_type="device" ) @@ -83,24 +94,20 @@ async def async_get_triggers( ) -> list[dict[str, str]]: """List device triggers. - Make sure the device supports device automations and - if it does return the trigger list. + Make sure the device supports device automations and return the trigger list. """ - zha_device = async_get_zha_device(hass, device_id) - - if not zha_device.device_automation_triggers: - return [] - - triggers = [] - for trigger, subtype in zha_device.device_automation_triggers: - triggers.append( - { - CONF_DEVICE_ID: device_id, - CONF_DOMAIN: ZHA_DOMAIN, - CONF_PLATFORM: DEVICE, - CONF_TYPE: trigger, - CONF_SUBTYPE: subtype, - } - ) - - return triggers + try: + _, triggers = _get_device_trigger_data(hass, device_id) + except KeyError as err: + raise InvalidDeviceAutomationConfig from err + + return [ + { + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: ZHA_DOMAIN, + CONF_PLATFORM: DEVICE, + CONF_TYPE: trigger, + CONF_SUBTYPE: subtype, + } + for trigger, subtype in triggers + ] diff --git a/homeassistant/components/zha/diagnostics.py b/homeassistant/components/zha/diagnostics.py index 966f35fe98bbbe..0fa1de5ff0ee5c 100644 --- a/homeassistant/components/zha/diagnostics.py +++ b/homeassistant/components/zha/diagnostics.py @@ -25,14 +25,10 @@ ATTR_PROFILE_ID, ATTR_VALUE, CONF_ALARM_MASTER_CODE, - DATA_ZHA, - DATA_ZHA_CONFIG, - DATA_ZHA_GATEWAY, UNKNOWN, ) from .core.device import ZHADevice -from .core.gateway import ZHAGateway -from .core.helpers import async_get_zha_device +from .core.helpers import async_get_zha_device, get_zha_data, get_zha_gateway KEYS_TO_REDACT = { ATTR_IEEE, @@ -66,18 +62,18 @@ async def async_get_config_entry_diagnostics( hass: HomeAssistant, config_entry: ConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - config: dict = hass.data[DATA_ZHA].get(DATA_ZHA_CONFIG, {}) - gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_data = get_zha_data(hass) + app = get_zha_gateway(hass).application_controller - energy_scan = await gateway.application_controller.energy_scan( + energy_scan = await app.energy_scan( channels=Channels.ALL_CHANNELS, duration_exp=4, count=1 ) return async_redact_data( { - "config": config, + "config": zha_data.yaml_config, "config_entry": config_entry.as_dict(), - "application_state": shallow_asdict(gateway.application_controller.state), + "application_state": shallow_asdict(app.state), "energy_scan": { channel: 100 * energy / 255 for channel, energy in energy_scan.items() }, diff --git a/homeassistant/components/zha/entity.py b/homeassistant/components/zha/entity.py index f2b16a3783423c..da34b8299078e9 100644 --- a/homeassistant/components/zha/entity.py +++ b/homeassistant/components/zha/entity.py @@ -26,14 +26,12 @@ from .core.const import ( ATTR_MANUFACTURER, ATTR_MODEL, - DATA_ZHA, - DATA_ZHA_BRIDGE_ID, DOMAIN, SIGNAL_GROUP_ENTITY_REMOVED, SIGNAL_GROUP_MEMBERSHIP_CHANGE, SIGNAL_REMOVE, ) -from .core.helpers import LogMixin +from .core.helpers import LogMixin, get_zha_gateway if TYPE_CHECKING: from .core.cluster_handlers import ClusterHandler @@ -61,7 +59,6 @@ def __init__(self, unique_id: str, zha_device: ZHADevice, **kwargs: Any) -> None self._extra_state_attributes: dict[str, Any] = {} self._zha_device = zha_device self._unsubs: list[Callable[[], None]] = [] - self.remove_future: asyncio.Future[Any] = asyncio.Future() @property def unique_id(self) -> str: @@ -83,13 +80,16 @@ def device_info(self) -> DeviceInfo: """Return a device description for device registry.""" zha_device_info = self._zha_device.device_info ieee = zha_device_info["ieee"] + + zha_gateway = get_zha_gateway(self.hass) + return DeviceInfo( connections={(CONNECTION_ZIGBEE, ieee)}, identifiers={(DOMAIN, ieee)}, manufacturer=zha_device_info[ATTR_MANUFACTURER], model=zha_device_info[ATTR_MODEL], name=zha_device_info[ATTR_NAME], - via_device=(DOMAIN, self.hass.data[DATA_ZHA][DATA_ZHA_BRIDGE_ID]), + via_device=(DOMAIN, zha_gateway.coordinator_ieee), ) @callback @@ -142,6 +142,8 @@ def log(self, level: int, msg: str, *args, **kwargs): class ZhaEntity(BaseZhaEntity, RestoreEntity): """A base class for non group ZHA entities.""" + remove_future: asyncio.Future[Any] + def __init_subclass__(cls, id_suffix: str | None = None, **kwargs: Any) -> None: """Initialize subclass. @@ -187,7 +189,7 @@ def available(self) -> bool: async def async_added_to_hass(self) -> None: """Run when about to be added to hass.""" - self.remove_future = asyncio.Future() + self.remove_future = self.hass.loop.create_future() self.async_accept_signal( None, f"{SIGNAL_REMOVE}_{self.zha_device.ieee}", diff --git a/homeassistant/components/zha/fan.py b/homeassistant/components/zha/fan.py index a24272c9a7a0d8..73b128db10947f 100644 --- a/homeassistant/components/zha/fan.py +++ b/homeassistant/components/zha/fan.py @@ -28,12 +28,8 @@ from .core import discovery from .core.cluster_handlers import wrap_zigpy_exceptions -from .core.const import ( - CLUSTER_HANDLER_FAN, - DATA_ZHA, - SIGNAL_ADD_ENTITIES, - SIGNAL_ATTR_UPDATED, -) +from .core.const import CLUSTER_HANDLER_FAN, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED +from .core.helpers import get_zha_data from .core.registries import ZHA_ENTITIES from .entity import ZhaEntity, ZhaGroupEntity @@ -65,7 +61,8 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Zigbee Home Automation fan from config entry.""" - entities_to_create = hass.data[DATA_ZHA][Platform.FAN] + zha_data = get_zha_data(hass) + entities_to_create = zha_data.platforms[Platform.FAN] unsub = async_dispatcher_connect( hass, diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py index 2ec424314980ad..967d0fc9134694 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -47,13 +47,12 @@ CONF_ENABLE_ENHANCED_LIGHT_TRANSITION, CONF_ENABLE_LIGHT_TRANSITIONING_FLAG, CONF_GROUP_MEMBERS_ASSUME_STATE, - DATA_ZHA, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, SIGNAL_SET_LEVEL, ZHA_OPTIONS, ) -from .core.helpers import LogMixin, async_get_zha_config_value +from .core.helpers import LogMixin, async_get_zha_config_value, get_zha_data from .core.registries import ZHA_ENTITIES from .entity import ZhaEntity, ZhaGroupEntity @@ -97,7 +96,8 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Zigbee Home Automation light from config entry.""" - entities_to_create = hass.data[DATA_ZHA][Platform.LIGHT] + zha_data = get_zha_data(hass) + entities_to_create = zha_data.platforms[Platform.LIGHT] unsub = async_dispatcher_connect( hass, diff --git a/homeassistant/components/zha/lock.py b/homeassistant/components/zha/lock.py index 1e68e95c88142e..9bac9a59a389ff 100644 --- a/homeassistant/components/zha/lock.py +++ b/homeassistant/components/zha/lock.py @@ -20,10 +20,10 @@ from .core import discovery from .core.const import ( CLUSTER_HANDLER_DOORLOCK, - DATA_ZHA, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, ) +from .core.helpers import get_zha_data from .core.registries import ZHA_ENTITIES from .entity import ZhaEntity @@ -45,7 +45,8 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Zigbee Home Automation Door Lock from config entry.""" - entities_to_create = hass.data[DATA_ZHA][Platform.LOCK] + zha_data = get_zha_data(hass) + entities_to_create = zha_data.platforms[Platform.LOCK] unsub = async_dispatcher_connect( hass, diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 809b576defae69..c3fa6b1ff01f02 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -21,13 +21,13 @@ "universal_silabs_flasher" ], "requirements": [ - "bellows==0.36.1", + "bellows==0.36.3", "pyserial==3.5", "pyserial-asyncio==0.6", "zha-quirks==0.0.103", - "zigpy-deconz==0.21.0", - "zigpy==0.57.0", - "zigpy-xbee==0.18.1", + "zigpy-deconz==0.21.1", + "zigpy==0.57.1", + "zigpy-xbee==0.18.2", "zigpy-zigate==0.11.0", "zigpy-znp==0.11.4", "universal-silabs-flasher==0.0.13" diff --git a/homeassistant/components/zha/number.py b/homeassistant/components/zha/number.py index c12060eb2a8542..b6876155312fc8 100644 --- a/homeassistant/components/zha/number.py +++ b/homeassistant/components/zha/number.py @@ -20,10 +20,10 @@ CLUSTER_HANDLER_COLOR, CLUSTER_HANDLER_INOVELLI, CLUSTER_HANDLER_LEVEL, - DATA_ZHA, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, ) +from .core.helpers import get_zha_data from .core.registries import ZHA_ENTITIES from .entity import ZhaEntity @@ -258,7 +258,8 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Zigbee Home Automation Analog Output from config entry.""" - entities_to_create = hass.data[DATA_ZHA][Platform.NUMBER] + zha_data = get_zha_data(hass) + entities_to_create = zha_data.platforms[Platform.NUMBER] unsub = async_dispatcher_connect( hass, diff --git a/homeassistant/components/zha/radio_manager.py b/homeassistant/components/zha/radio_manager.py index 751fea99847d99..ca03060075171f 100644 --- a/homeassistant/components/zha/radio_manager.py +++ b/homeassistant/components/zha/radio_manager.py @@ -8,7 +8,7 @@ import enum import logging import os -from typing import Any +from typing import Any, Self from bellows.config import CONF_USE_THREAD import voluptuous as vol @@ -26,12 +26,11 @@ CONF_DATABASE, CONF_RADIO_TYPE, CONF_ZIGPY, - DATA_ZHA, - DATA_ZHA_CONFIG, DEFAULT_DATABASE_NAME, EZSP_OVERWRITE_EUI64, RadioType, ) +from .core.helpers import get_zha_data # Only the common radio types will be autoprobed, ordered by new device popularity. # XBee takes too long to probe since it scans through all possible bauds and likely has @@ -127,12 +126,25 @@ def __init__(self) -> None: self.backups: list[zigpy.backups.NetworkBackup] = [] self.chosen_backup: zigpy.backups.NetworkBackup | None = None + @classmethod + def from_config_entry( + cls, hass: HomeAssistant, config_entry: config_entries.ConfigEntry + ) -> Self: + """Create an instance from a config entry.""" + mgr = cls() + mgr.hass = hass + mgr.device_path = config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH] + mgr.device_settings = config_entry.data[CONF_DEVICE] + mgr.radio_type = RadioType[config_entry.data[CONF_RADIO_TYPE]] + + return mgr + @contextlib.asynccontextmanager - async def _connect_zigpy_app(self) -> ControllerApplication: + async def connect_zigpy_app(self) -> ControllerApplication: """Connect to the radio with the current config and then clean up.""" assert self.radio_type is not None - config = self.hass.data.get(DATA_ZHA, {}).get(DATA_ZHA_CONFIG, {}) + config = get_zha_data(self.hass).yaml_config app_config = config.get(CONF_ZIGPY, {}).copy() database_path = config.get( @@ -155,10 +167,9 @@ async def _connect_zigpy_app(self) -> ControllerApplication: ) try: - await app.connect() yield app finally: - await app.disconnect() + await app.shutdown() await asyncio.sleep(CONNECT_DELAY_S) async def restore_backup( @@ -170,7 +181,8 @@ async def restore_backup( ): return - async with self._connect_zigpy_app() as app: + async with self.connect_zigpy_app() as app: + await app.connect() await app.backups.restore_backup(backup, **kwargs) @staticmethod @@ -218,7 +230,9 @@ async def async_load_network_settings( """Connect to the radio and load its current network settings.""" backup = None - async with self._connect_zigpy_app() as app: + async with self.connect_zigpy_app() as app: + await app.connect() + # Check if the stick has any settings and load them try: await app.load_network_info() @@ -241,12 +255,14 @@ async def async_load_network_settings( async def async_form_network(self) -> None: """Form a brand-new network.""" - async with self._connect_zigpy_app() as app: + async with self.connect_zigpy_app() as app: + await app.connect() await app.form_network() async def async_reset_adapter(self) -> None: """Reset the current adapter.""" - async with self._connect_zigpy_app() as app: + async with self.connect_zigpy_app() as app: + await app.connect() await app.reset_network_info() async def async_restore_backup_step_1(self) -> bool: diff --git a/homeassistant/components/zha/select.py b/homeassistant/components/zha/select.py index 018f24675e7023..fa2e124fd05495 100644 --- a/homeassistant/components/zha/select.py +++ b/homeassistant/components/zha/select.py @@ -23,11 +23,11 @@ CLUSTER_HANDLER_IAS_WD, CLUSTER_HANDLER_INOVELLI, CLUSTER_HANDLER_ON_OFF, - DATA_ZHA, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, Strobe, ) +from .core.helpers import get_zha_data from .core.registries import ZHA_ENTITIES from .entity import ZhaEntity @@ -48,7 +48,8 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Zigbee Home Automation siren from config entry.""" - entities_to_create = hass.data[DATA_ZHA][Platform.SELECT] + zha_data = get_zha_data(hass) + entities_to_create = zha_data.platforms[Platform.SELECT] unsub = async_dispatcher_connect( hass, diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index 535733230b93a9..1e166675b5b75f 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -57,10 +57,10 @@ CLUSTER_HANDLER_SOIL_MOISTURE, CLUSTER_HANDLER_TEMPERATURE, CLUSTER_HANDLER_THERMOSTAT, - DATA_ZHA, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, ) +from .core.helpers import get_zha_data from .core.registries import SMARTTHINGS_HUMIDITY_CLUSTER, ZHA_ENTITIES from .entity import ZhaEntity @@ -99,7 +99,8 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Zigbee Home Automation sensor from config entry.""" - entities_to_create = hass.data[DATA_ZHA][Platform.SENSOR] + zha_data = get_zha_data(hass) + entities_to_create = zha_data.platforms[Platform.SENSOR] unsub = async_dispatcher_connect( hass, diff --git a/homeassistant/components/zha/siren.py b/homeassistant/components/zha/siren.py index a4c699d515ba63..86cadb625198d9 100644 --- a/homeassistant/components/zha/siren.py +++ b/homeassistant/components/zha/siren.py @@ -25,7 +25,6 @@ from .core.cluster_handlers.security import IasWd from .core.const import ( CLUSTER_HANDLER_IAS_WD, - DATA_ZHA, SIGNAL_ADD_ENTITIES, WARNING_DEVICE_MODE_BURGLAR, WARNING_DEVICE_MODE_EMERGENCY, @@ -39,6 +38,7 @@ WARNING_DEVICE_STROBE_NO, Strobe, ) +from .core.helpers import get_zha_data from .core.registries import ZHA_ENTITIES from .entity import ZhaEntity @@ -56,7 +56,8 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Zigbee Home Automation siren from config entry.""" - entities_to_create = hass.data[DATA_ZHA][Platform.SIREN] + zha_data = get_zha_data(hass) + entities_to_create = zha_data.platforms[Platform.SIREN] unsub = async_dispatcher_connect( hass, diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index 87738e821ea350..f5bebb1e9635b5 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -508,7 +508,7 @@ "issues": { "wrong_silabs_firmware_installed_nabucasa": { "title": "Zigbee radio with multiprotocol firmware detected", - "description": "Your Zigbee radio was previously used with multiprotocol (Zigbee and Thread) and still has multiprotocol firmware installed: ({firmware_type}). \n Option 1: To run your radio exclusively with ZHA, you need to install the Zigbee firmware:\n - Open the documentation by selecting the link under \"Learn More\".\n -. Follow the instructions described in the step to flash the Zigbee firmware.\n Option 2: To run your radio with multiprotocol, follow these steps: \n - Go to Settings > System > Hardware, select the device and select Configure. \n - Select the Configure IEEE 802.15.4 radio multiprotocol support option. \n - Select the checkbox and select Submit. \n - Once installed, configure the newly discovered ZHA integration." + "description": "Your Zigbee radio was previously used with multiprotocol (Zigbee and Thread) and still has multiprotocol firmware installed: ({firmware_type}). \n Option 1: To run your radio exclusively with ZHA, you need to install the Zigbee firmware:\n - Open the documentation by selecting the link under \"Learn More\".\n - Follow the instructions described in the step to flash the Zigbee firmware.\n Option 2: To run your radio with multiprotocol, follow these steps: \n - Go to Settings > System > Hardware, select the device and select Configure. \n - Select the Configure IEEE 802.15.4 radio multiprotocol support option. \n - Select the checkbox and select Submit. \n - Once installed, configure the newly discovered ZHA integration." }, "wrong_silabs_firmware_installed_other": { "title": "[%key:component::zha::issues::wrong_silabs_firmware_installed_nabucasa::title%]", diff --git a/homeassistant/components/zha/switch.py b/homeassistant/components/zha/switch.py index 8707dda629fe91..eff8f727c1c896 100644 --- a/homeassistant/components/zha/switch.py +++ b/homeassistant/components/zha/switch.py @@ -20,10 +20,10 @@ CLUSTER_HANDLER_BASIC, CLUSTER_HANDLER_INOVELLI, CLUSTER_HANDLER_ON_OFF, - DATA_ZHA, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, ) +from .core.helpers import get_zha_data from .core.registries import ZHA_ENTITIES from .entity import ZhaEntity, ZhaGroupEntity @@ -46,7 +46,8 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Zigbee Home Automation switch from config entry.""" - entities_to_create = hass.data[DATA_ZHA][Platform.SWITCH] + zha_data = get_zha_data(hass) + entities_to_create = zha_data.platforms[Platform.SWITCH] unsub = async_dispatcher_connect( hass, diff --git a/homeassistant/components/zha/websocket_api.py b/homeassistant/components/zha/websocket_api.py index 97862bd36f05dd..51941248f03cbc 100644 --- a/homeassistant/components/zha/websocket_api.py +++ b/homeassistant/components/zha/websocket_api.py @@ -16,6 +16,7 @@ from homeassistant.components import websocket_api from homeassistant.const import ATTR_COMMAND, ATTR_ID, ATTR_NAME from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.helpers import entity_registry as er import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.service import async_register_admin_service @@ -52,8 +53,6 @@ CLUSTER_TYPE_IN, CLUSTER_TYPE_OUT, CUSTOM_CONFIGURATION, - DATA_ZHA, - DATA_ZHA_GATEWAY, DOMAIN, EZSP_OVERWRITE_EUI64, GROUP_ID, @@ -77,6 +76,7 @@ cluster_command_schema_to_vol_schema, convert_install_code, get_matched_clusters, + get_zha_gateway, qr_to_install_code, ) @@ -301,7 +301,7 @@ async def websocket_permit_devices( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Permit ZHA zigbee devices.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) duration: int = msg[ATTR_DURATION] ieee: EUI64 | None = msg.get(ATTR_IEEE) @@ -348,7 +348,7 @@ async def websocket_get_devices( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Get ZHA devices.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) devices = [device.zha_device_info for device in zha_gateway.devices.values()] connection.send_result(msg[ID], devices) @@ -357,7 +357,8 @@ async def websocket_get_devices( def _get_entity_name( zha_gateway: ZHAGateway, entity_ref: EntityReference ) -> str | None: - entry = zha_gateway.ha_entity_registry.async_get(entity_ref.reference_id) + entity_registry = er.async_get(zha_gateway.hass) + entry = entity_registry.async_get(entity_ref.reference_id) return entry.name if entry else None @@ -365,7 +366,8 @@ def _get_entity_name( def _get_entity_original_name( zha_gateway: ZHAGateway, entity_ref: EntityReference ) -> str | None: - entry = zha_gateway.ha_entity_registry.async_get(entity_ref.reference_id) + entity_registry = er.async_get(zha_gateway.hass) + entry = entity_registry.async_get(entity_ref.reference_id) return entry.original_name if entry else None @@ -376,7 +378,7 @@ async def websocket_get_groupable_devices( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Get ZHA devices that can be grouped.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) devices = [device for device in zha_gateway.devices.values() if device.is_groupable] groupable_devices = [] @@ -414,7 +416,7 @@ async def websocket_get_groups( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Get ZHA groups.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) groups = [group.group_info for group in zha_gateway.groups.values()] connection.send_result(msg[ID], groups) @@ -431,7 +433,7 @@ async def websocket_get_device( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Get ZHA devices.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) ieee: EUI64 = msg[ATTR_IEEE] if not (zha_device := zha_gateway.devices.get(ieee)): @@ -458,7 +460,7 @@ async def websocket_get_group( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Get ZHA group.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) group_id: int = msg[GROUP_ID] if not (zha_group := zha_gateway.groups.get(group_id)): @@ -487,7 +489,7 @@ async def websocket_add_group( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Add a new ZHA group.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) group_name: str = msg[GROUP_NAME] group_id: int | None = msg.get(GROUP_ID) members: list[GroupMember] | None = msg.get(ATTR_MEMBERS) @@ -508,7 +510,7 @@ async def websocket_remove_groups( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Remove the specified ZHA groups.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) group_ids: list[int] = msg[GROUP_IDS] if len(group_ids) > 1: @@ -535,7 +537,7 @@ async def websocket_add_group_members( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Add members to a ZHA group.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) group_id: int = msg[GROUP_ID] members: list[GroupMember] = msg[ATTR_MEMBERS] @@ -565,7 +567,7 @@ async def websocket_remove_group_members( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Remove members from a ZHA group.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) group_id: int = msg[GROUP_ID] members: list[GroupMember] = msg[ATTR_MEMBERS] @@ -594,7 +596,7 @@ async def websocket_reconfigure_node( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Reconfigure a ZHA nodes entities by its ieee address.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) ieee: EUI64 = msg[ATTR_IEEE] device: ZHADevice | None = zha_gateway.get_device(ieee) @@ -629,7 +631,7 @@ async def websocket_update_topology( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Update the ZHA network topology.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) hass.async_create_task(zha_gateway.application_controller.topology.scan()) @@ -645,7 +647,7 @@ async def websocket_device_clusters( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Return a list of device clusters.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) ieee: EUI64 = msg[ATTR_IEEE] zha_device = zha_gateway.get_device(ieee) response_clusters = [] @@ -689,7 +691,7 @@ async def websocket_device_cluster_attributes( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Return a list of cluster attributes.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) ieee: EUI64 = msg[ATTR_IEEE] endpoint_id: int = msg[ATTR_ENDPOINT_ID] cluster_id: int = msg[ATTR_CLUSTER_ID] @@ -736,7 +738,7 @@ async def websocket_device_cluster_commands( """Return a list of cluster commands.""" import voluptuous_serialize # pylint: disable=import-outside-toplevel - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) ieee: EUI64 = msg[ATTR_IEEE] endpoint_id: int = msg[ATTR_ENDPOINT_ID] cluster_id: int = msg[ATTR_CLUSTER_ID] @@ -806,7 +808,7 @@ async def websocket_read_zigbee_cluster_attributes( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Read zigbee attribute for cluster on ZHA entity.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) ieee: EUI64 = msg[ATTR_IEEE] endpoint_id: int = msg[ATTR_ENDPOINT_ID] cluster_id: int = msg[ATTR_CLUSTER_ID] @@ -860,7 +862,7 @@ async def websocket_get_bindable_devices( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Directly bind devices.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) source_ieee: EUI64 = msg[ATTR_IEEE] source_device = zha_gateway.get_device(source_ieee) @@ -894,7 +896,7 @@ async def websocket_bind_devices( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Directly bind devices.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) source_ieee: EUI64 = msg[ATTR_SOURCE_IEEE] target_ieee: EUI64 = msg[ATTR_TARGET_IEEE] await async_binding_operation( @@ -923,7 +925,7 @@ async def websocket_unbind_devices( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Remove a direct binding between devices.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) source_ieee: EUI64 = msg[ATTR_SOURCE_IEEE] target_ieee: EUI64 = msg[ATTR_TARGET_IEEE] await async_binding_operation( @@ -953,7 +955,7 @@ async def websocket_bind_group( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Directly bind a device to a group.""" - zha_gateway: ZHAGateway = get_gateway(hass) + zha_gateway = get_zha_gateway(hass) source_ieee: EUI64 = msg[ATTR_SOURCE_IEEE] group_id: int = msg[GROUP_ID] bindings: list[ClusterBinding] = msg[BINDINGS] @@ -977,7 +979,7 @@ async def websocket_unbind_group( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Unbind a device from a group.""" - zha_gateway: ZHAGateway = get_gateway(hass) + zha_gateway = get_zha_gateway(hass) source_ieee: EUI64 = msg[ATTR_SOURCE_IEEE] group_id: int = msg[GROUP_ID] bindings: list[ClusterBinding] = msg[BINDINGS] @@ -987,11 +989,6 @@ async def websocket_unbind_group( connection.send_result(msg[ID]) -def get_gateway(hass: HomeAssistant) -> ZHAGateway: - """Return Gateway, mainly as fixture for mocking during testing.""" - return hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - - async def async_binding_operation( zha_gateway: ZHAGateway, source_ieee: EUI64, @@ -1047,7 +1044,7 @@ async def websocket_get_configuration( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Get ZHA configuration.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) import voluptuous_serialize # pylint: disable=import-outside-toplevel def custom_serializer(schema: Any) -> Any: @@ -1094,7 +1091,7 @@ async def websocket_update_zha_configuration( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Update the ZHA configuration.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) options = zha_gateway.config_entry.options data_to_save = {**options, **{CUSTOM_CONFIGURATION: msg["data"]}} @@ -1141,7 +1138,7 @@ async def websocket_get_network_settings( ) -> None: """Get ZHA network settings.""" backup = async_get_active_network_settings(hass) - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) connection.send_result( msg[ID], { @@ -1159,7 +1156,7 @@ async def websocket_list_network_backups( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Get ZHA network settings.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) application_controller = zha_gateway.application_controller # Serialize known backups @@ -1175,7 +1172,7 @@ async def websocket_create_network_backup( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Create a ZHA network backup.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) application_controller = zha_gateway.application_controller # This can take 5-30s @@ -1202,7 +1199,7 @@ async def websocket_restore_network_backup( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Restore a ZHA network backup.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) application_controller = zha_gateway.application_controller backup = msg["backup"] @@ -1240,7 +1237,7 @@ async def websocket_change_channel( @callback def async_load_api(hass: HomeAssistant) -> None: """Set up the web socket API.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) application_controller = zha_gateway.application_controller async def permit(service: ServiceCall) -> None: @@ -1278,7 +1275,7 @@ async def permit(service: ServiceCall) -> None: async def remove(service: ServiceCall) -> None: """Remove a node from the network.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) ieee: EUI64 = service.data[ATTR_IEEE] zha_device: ZHADevice | None = zha_gateway.get_device(ieee) if zha_device is not None and zha_device.is_active_coordinator: diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index c879cc1f5b459a..d54dc659be1e46 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -588,6 +588,19 @@ class ZWaveDiscoverySchema: ), absent_values=[SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA], ), + # Logic Group ZDB5100 + ZWaveDiscoverySchema( + platform=Platform.LIGHT, + hint="black_is_off", + manufacturer_id={0x0234}, + product_id={0x0121}, + product_type={0x0003}, + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.SWITCH_COLOR}, + property={CURRENT_COLOR_PROPERTY}, + property_key={None}, + ), + ), # ====== START OF GENERIC MAPPING SCHEMAS ======= # locks # Door Lock CC diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index 73fa41a8cca1ec..cfb2c239d8ef3c 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -9,7 +9,7 @@ "iot_class": "local_push", "loggers": ["zwave_js_server"], "quality_scale": "platinum", - "requirements": ["pyserial==3.5", "zwave-js-server-python==0.51.0"], + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.51.3"], "usb": [ { "vid": "0658", diff --git a/homeassistant/components/zwave_js/repairs.py b/homeassistant/components/zwave_js/repairs.py index 89f51dddb88e04..83ee0523a3b5c0 100644 --- a/homeassistant/components/zwave_js/repairs.py +++ b/homeassistant/components/zwave_js/repairs.py @@ -2,7 +2,6 @@ from __future__ import annotations import voluptuous as vol -from zwave_js_server.model.node import Node from homeassistant import data_entry_flow from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow @@ -14,10 +13,10 @@ class DeviceConfigFileChangedFlow(RepairsFlow): """Handler for an issue fixing flow.""" - def __init__(self, node: Node, device_name: str) -> None: + def __init__(self, data: dict[str, str]) -> None: """Initialize.""" - self.node = node - self.device_name = device_name + self.device_name: str = data["device_name"] + self.device_id: str = data["device_id"] async def async_step_init( self, user_input: dict[str, str] | None = None @@ -30,7 +29,14 @@ async def async_step_confirm( ) -> data_entry_flow.FlowResult: """Handle the confirm step of a fix flow.""" if user_input is not None: - self.hass.async_create_task(self.node.async_refresh_info()) + try: + node = async_get_node_from_device_id(self.hass, self.device_id) + except ValueError: + return self.async_abort( + reason="cannot_connect", + description_placeholders={"device_name": self.device_name}, + ) + self.hass.async_create_task(node.async_refresh_info()) return self.async_create_entry(title="", data={}) return self.async_show_form( @@ -41,15 +47,11 @@ async def async_step_confirm( async def async_create_fix_flow( - hass: HomeAssistant, - issue_id: str, - data: dict[str, str] | None, + hass: HomeAssistant, issue_id: str, data: dict[str, str] | None ) -> RepairsFlow: """Create flow.""" if issue_id.split(".")[0] == "device_config_file_changed": assert data - return DeviceConfigFileChangedFlow( - async_get_node_from_device_id(hass, data["device_id"]), data["device_name"] - ) + return DeviceConfigFileChangedFlow(data) return ConfirmRepairFlow() diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index 3c22288a1d6965..8d42bcfb36698f 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -17,7 +17,7 @@ from zwave_js_server.model.driver import Driver from zwave_js_server.model.node import Node as ZwaveNode from zwave_js_server.model.node.statistics import NodeStatisticsDataType -from zwave_js_server.model.value import ConfigurationValue, ConfigurationValueType +from zwave_js_server.model.value import ConfigurationValue from zwave_js_server.util.command_class.meter import get_meter_type from homeassistant.components.sensor import ( @@ -729,22 +729,9 @@ def __init__( alternate_value_name=self.info.primary_value.property_name, additional_info=[self.info.primary_value.property_key_name], ) - - @property - def options(self) -> list[str] | None: - """Return options for enum sensor.""" - if self.device_class == SensorDeviceClass.ENUM: - return list(self.info.primary_value.metadata.states.values()) - return None - - @property - def device_class(self) -> SensorDeviceClass | None: - """Return sensor device class.""" - if (device_class := super().device_class) is not None: - return device_class if self.info.primary_value.metadata.states: - return SensorDeviceClass.ENUM - return None + self._attr_device_class = SensorDeviceClass.ENUM + self._attr_options = list(info.primary_value.metadata.states.values()) @property def extra_state_attributes(self) -> dict[str, str] | None: @@ -781,19 +768,6 @@ def __init__( additional_info=[property_key_name] if property_key_name else None, ) - @property - def device_class(self) -> SensorDeviceClass | None: - """Return sensor device class.""" - # mypy doesn't know about fget: https://github.com/python/mypy/issues/6185 - if (device_class := ZwaveSensor.device_class.fget(self)) is not None: # type: ignore[attr-defined] - return device_class # type: ignore[no-any-return] - if ( - self._primary_value.configuration_value_type - == ConfigurationValueType.ENUMERATED - ): - return SensorDeviceClass.ENUM - return None - @property def extra_state_attributes(self) -> dict[str, str] | None: """Return the device specific state attributes.""" diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index 6435c6b7a544d4..6994ce15a0ca38 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -170,6 +170,9 @@ "title": "Z-Wave device configuration file changed: {device_name}", "description": "Z-Wave JS discovers a lot of device metadata by interviewing the device. However, some of the information has to be loaded from a configuration file. Some of this information is only evaluated once, during the device interview.\n\nWhen a device config file is updated, this information may be stale and and the device must be re-interviewed to pick up the changes.\n\n This is not a required operation and device functionality will be impacted during the re-interview process, but you may see improvements for your device once it is complete.\n\nIf you'd like to proceed, click on SUBMIT below. The re-interview will take place in the background." } + }, + "abort": { + "cannot_connect": "Cannot connect to {device_name}. Please try again later after confirming that your Z-Wave network is up and connected to Home Assistant." } } } diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index a3b03407a142d0..ed5ba79c1b42af 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -148,6 +148,11 @@ def recoverable(self) -> bool: SIGNAL_CONFIG_ENTRY_CHANGED = "config_entry_changed" +NO_RESET_TRIES_STATES = { + ConfigEntryState.SETUP_RETRY, + ConfigEntryState.SETUP_IN_PROGRESS, +} + class ConfigEntryChange(StrEnum): """What was changed in a config entry.""" @@ -220,6 +225,9 @@ class ConfigEntry: "reload_lock", "_tasks", "_background_tasks", + "_integration_for_domain", + "_tries", + "_setup_again_job", ) def __init__( @@ -317,12 +325,15 @@ def __init__( self._tasks: set[asyncio.Future[Any]] = set() self._background_tasks: set[asyncio.Future[Any]] = set() + self._integration_for_domain: loader.Integration | None = None + self._tries = 0 + self._setup_again_job: HassJob | None = None + async def async_setup( self, hass: HomeAssistant, *, integration: loader.Integration | None = None, - tries: int = 0, ) -> None: """Set up an entry.""" current_entry.set(self) @@ -331,6 +342,7 @@ async def async_setup( if integration is None: integration = await loader.async_get_integration(hass, self.domain) + self._integration_for_domain = integration # Only store setup result as state if it was not forwarded. if self.domain == integration.domain: @@ -419,13 +431,13 @@ async def async_setup( result = False except ConfigEntryNotReady as ex: self._async_set_state(hass, ConfigEntryState.SETUP_RETRY, str(ex) or None) - wait_time = 2 ** min(tries, 4) * 5 + ( + wait_time = 2 ** min(self._tries, 4) * 5 + ( randint(RANDOM_MICROSECOND_MIN, RANDOM_MICROSECOND_MAX) / 1000000 ) - tries += 1 + self._tries += 1 message = str(ex) ready_message = f"ready yet: {message}" if message else "ready yet" - if tries == 1: + if self._tries == 1: _LOGGER.warning( ( "Config entry '%s' for %s integration not %s; Retrying in" @@ -447,22 +459,14 @@ async def async_setup( wait_time, ) - async def setup_again(*_: Any) -> None: - """Run setup again.""" - # Check again when we fire in case shutdown - # has started so we do not block shutdown - if hass.is_stopping: - return - self._async_cancel_retry_setup = None - await self.async_setup(hass, integration=integration, tries=tries) - if hass.state == CoreState.running: self._async_cancel_retry_setup = async_call_later( - hass, wait_time, setup_again + hass, wait_time, self._async_get_setup_again_job(hass) ) else: self._async_cancel_retry_setup = hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_STARTED, setup_again + EVENT_HOMEASSISTANT_STARTED, + functools.partial(self._async_setup_again, hass), ) await self._async_process_on_unload(hass) @@ -483,6 +487,24 @@ async def setup_again(*_: Any) -> None: else: self._async_set_state(hass, ConfigEntryState.SETUP_ERROR, error_reason) + async def _async_setup_again(self, hass: HomeAssistant, *_: Any) -> None: + """Run setup again.""" + # Check again when we fire in case shutdown + # has started so we do not block shutdown + if not hass.is_stopping: + self._async_cancel_retry_setup = None + await self.async_setup(hass) + + @callback + def _async_get_setup_again_job(self, hass: HomeAssistant) -> HassJob: + """Get a job that will call setup again.""" + if not self._setup_again_job: + self._setup_again_job = HassJob( + functools.partial(self._async_setup_again, hass), + cancel_on_shutdown=True, + ) + return self._setup_again_job + async def async_shutdown(self) -> None: """Call when Home Assistant is stopping.""" self.async_cancel_retry_setup() @@ -508,7 +530,7 @@ async def async_unload( if self.state == ConfigEntryState.NOT_LOADED: return True - if integration is None: + if not integration and (integration := self._integration_for_domain) is None: try: integration = await loader.async_get_integration(hass, self.domain) except loader.IntegrationNotFound: @@ -566,14 +588,15 @@ async def async_remove(self, hass: HomeAssistant) -> None: if self.source == SOURCE_IGNORE: return - try: - integration = await loader.async_get_integration(hass, self.domain) - except loader.IntegrationNotFound: - # The integration was likely a custom_component - # that was uninstalled, or an integration - # that has been renamed without removing the config - # entry. - return + if not (integration := self._integration_for_domain): + try: + integration = await loader.async_get_integration(hass, self.domain) + except loader.IntegrationNotFound: + # The integration was likely a custom_component + # that was uninstalled, or an integration + # that has been renamed without removing the config + # entry. + return component = integration.get_component() if not hasattr(component, "async_remove_entry"): @@ -592,6 +615,8 @@ def _async_set_state( self, hass: HomeAssistant, state: ConfigEntryState, reason: str | None ) -> None: """Set the state of the config entry.""" + if state not in NO_RESET_TRIES_STATES: + self._tries = 0 self.state = state self.reason = reason async_dispatcher_send( @@ -617,7 +642,8 @@ async def async_migrate(self, hass: HomeAssistant) -> bool: if self.version == handler.VERSION: return True - integration = await loader.async_get_integration(hass, self.domain) + if not (integration := self._integration_for_domain): + integration = await loader.async_get_integration(hass, self.domain) component = integration.get_component() supports_migrate = hasattr(component, "async_migrate_entry") if not supports_migrate: @@ -833,7 +859,7 @@ async def async_init( flow_id = uuid_util.random_uuid_hex() if context["source"] == SOURCE_IMPORT: - init_done: asyncio.Future[None] = asyncio.Future() + init_done: asyncio.Future[None] = self.hass.loop.create_future() self._pending_import_flows.setdefault(handler, {})[flow_id] = init_done task = asyncio.create_task( @@ -1021,7 +1047,7 @@ def __init__(self, hass: HomeAssistant, hass_config: ConfigType) -> None: self.options = OptionsFlowManager(hass) self._hass_config = hass_config self._entries: dict[str, ConfigEntry] = {} - self._domain_index: dict[str, list[str]] = {} + self._domain_index: dict[str, list[ConfigEntry]] = {} self._store = storage.Store[dict[str, list[dict[str, Any]]]]( hass, STORAGE_VERSION, STORAGE_KEY ) @@ -1051,9 +1077,7 @@ def async_entries(self, domain: str | None = None) -> list[ConfigEntry]: """Return all entries or entries for a specific domain.""" if domain is None: return list(self._entries.values()) - return [ - self._entries[entry_id] for entry_id in self._domain_index.get(domain, []) - ] + return list(self._domain_index.get(domain, [])) async def async_add(self, entry: ConfigEntry) -> None: """Add and setup an entry.""" @@ -1062,7 +1086,7 @@ async def async_add(self, entry: ConfigEntry) -> None: f"An entry with the id {entry.entry_id} already exists." ) self._entries[entry.entry_id] = entry - self._domain_index.setdefault(entry.domain, []).append(entry.entry_id) + self._domain_index.setdefault(entry.domain, []).append(entry) self._async_dispatch(ConfigEntryChange.ADDED, entry) await self.async_setup(entry.entry_id) self._async_schedule_save() @@ -1080,7 +1104,7 @@ async def async_remove(self, entry_id: str) -> dict[str, Any]: await entry.async_remove(self.hass) del self._entries[entry.entry_id] - self._domain_index[entry.domain].remove(entry.entry_id) + self._domain_index[entry.domain].remove(entry) if not self._domain_index[entry.domain]: del self._domain_index[entry.domain] self._async_schedule_save() @@ -1147,7 +1171,7 @@ async def async_initialize(self) -> None: return entries = {} - domain_index: dict[str, list[str]] = {} + domain_index: dict[str, list[ConfigEntry]] = {} for entry in config["entries"]: pref_disable_new_entities = entry.get("pref_disable_new_entities") @@ -1162,7 +1186,7 @@ async def async_initialize(self) -> None: domain = entry["domain"] entry_id = entry["entry_id"] - entries[entry_id] = ConfigEntry( + config_entry = ConfigEntry( version=entry["version"], domain=domain, entry_id=entry_id, @@ -1181,7 +1205,8 @@ async def async_initialize(self) -> None: pref_disable_new_entities=pref_disable_new_entities, pref_disable_polling=entry.get("pref_disable_polling"), ) - domain_index.setdefault(domain, []).append(entry_id) + entries[entry_id] = config_entry + domain_index.setdefault(domain, []).append(config_entry) self._domain_index = domain_index self._entries = entries @@ -1322,7 +1347,7 @@ def async_update_entry( ("pref_disable_new_entities", pref_disable_new_entities), ("pref_disable_polling", pref_disable_polling), ): - if value == UNDEFINED or getattr(entry, attr) == value: + if value is UNDEFINED or getattr(entry, attr) == value: continue setattr(entry, attr, value) @@ -2055,7 +2080,9 @@ async def _async_get_flow_handler( """Get a flow handler for specified domain.""" # First check if there is a handler registered for the domain - if domain in hass.config.components and (handler := HANDLERS.get(domain)): + if loader.is_component_module_loaded(hass, f"{domain}.config_flow") and ( + handler := HANDLERS.get(domain) + ): return handler await _load_integration(hass, domain, hass_config) diff --git a/homeassistant/const.py b/homeassistant/const.py index 70f7827143b12f..de968451af9f45 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -460,6 +460,9 @@ class Platform(StrEnum): ATTR_LATITUDE: Final = "latitude" ATTR_LONGITUDE: Final = "longitude" +# Elevation of the entity +ATTR_ELEVATION: Final = "elevation" + # Accuracy of location in meters ATTR_GPS_ACCURACY: Final = "gps_accuracy" diff --git a/homeassistant/core.py b/homeassistant/core.py index 18c5c355ae979f..a50d43c1344f80 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -6,13 +6,15 @@ from __future__ import annotations import asyncio +from collections import UserDict, defaultdict from collections.abc import ( - Awaitable, Callable, Collection, Coroutine, Iterable, + KeysView, Mapping, + ValuesView, ) import concurrent.futures from contextlib import suppress @@ -32,7 +34,8 @@ import voluptuous as vol import yarl -from . import block_async_io, loader, util +from . import block_async_io, util +from .backports.functools import cached_property from .const import ( ATTR_DOMAIN, ATTR_FRIENDLY_NAME, @@ -92,6 +95,7 @@ from .auth import AuthManager from .components.http import ApiConfig, HomeAssistantHTTP from .config_entries import ConfigEntries + from .helpers.entity import StateInfo STAGE_1_SHUTDOWN_TIMEOUT = 100 @@ -174,6 +178,16 @@ def valid_entity_id(entity_id: str) -> bool: return VALID_ENTITY_ID.match(entity_id) is not None +def validate_state(state: str) -> str: + """Validate a state, raise if it not valid.""" + if len(state) > MAX_LENGTH_STATE_STATE: + raise InvalidStateError( + f"Invalid state with length {len(state)}. " + "State max length is 255 characters." + ) + return state + + def callback(func: _CallableT) -> _CallableT: """Annotation to mark method as safe to call from within the event loop.""" setattr(func, "_hass_callback", True) @@ -294,8 +308,15 @@ def __new__(cls, config_dir: str) -> HomeAssistant: _hass.hass = hass return hass + def __repr__(self) -> str: + """Return the representation.""" + return f"" + def __init__(self, config_dir: str) -> None: """Initialize new Home Assistant object.""" + # pylint: disable-next=import-outside-toplevel + from . import loader + self.loop = asyncio.get_running_loop() self._tasks: set[asyncio.Future[Any]] = set() self._background_tasks: set[asyncio.Future[Any]] = set() @@ -697,7 +718,9 @@ async def async_block_till_done(self) -> None: for task in tasks: _LOGGER.debug("Waiting for task: %s", task) - async def _await_and_log_pending(self, pending: Collection[Awaitable[Any]]) -> None: + async def _await_and_log_pending( + self, pending: Collection[asyncio.Future[Any]] + ) -> None: """Await and log tasks that take a long time.""" wait_time = 0 while pending: @@ -941,7 +964,7 @@ def as_dict(self) -> ReadOnlyDict[str, Any]: { "event_type": self.event_type, "data": ReadOnlyDict(self.data), - "origin": str(self.origin.value), + "origin": self.origin.value, "time_fired": self.time_fired.isoformat(), "context": self.context.as_dict(), } @@ -1218,20 +1241,6 @@ class State: object_id: Object id of this state. """ - __slots__ = ( - "entity_id", - "state", - "attributes", - "last_changed", - "last_updated", - "context", - "domain", - "object_id", - "_as_dict", - "_as_dict_json", - "_as_compressed_state_json", - ) - def __init__( self, entity_id: str, @@ -1241,6 +1250,7 @@ def __init__( last_updated: datetime.datetime | None = None, context: Context | None = None, validate_entity_id: bool | None = True, + state_info: StateInfo | None = None, ) -> None: """Initialize a new state.""" state = str(state) @@ -1251,22 +1261,17 @@ def __init__( "Format should be ." ) - if len(state) > MAX_LENGTH_STATE_STATE: - raise InvalidStateError( - f"Invalid state encountered for entity ID: {entity_id}. " - "State max length is 255 characters." - ) + validate_state(state) - self.entity_id = entity_id.lower() + self.entity_id = entity_id self.state = state self.attributes = ReadOnlyDict(attributes or {}) self.last_updated = last_updated or dt_util.utcnow() self.last_changed = last_changed or self.last_updated self.context = context or Context() + self.state_info = state_info self.domain, self.object_id = split_entity_id(self.entity_id) self._as_dict: ReadOnlyDict[str, Collection[Any]] | None = None - self._as_dict_json: str | None = None - self._as_compressed_state_json: str | None = None @property def name(self) -> str: @@ -1301,12 +1306,12 @@ def as_dict(self) -> ReadOnlyDict[str, Collection[Any]]: ) return self._as_dict + @cached_property def as_dict_json(self) -> str: """Return a JSON string of the State.""" - if not self._as_dict_json: - self._as_dict_json = json_dumps(self.as_dict()) - return self._as_dict_json + return json_dumps(self.as_dict()) + @cached_property def as_compressed_state(self) -> dict[str, Any]: """Build a compressed dict of a state for adds. @@ -1331,6 +1336,7 @@ def as_compressed_state(self) -> dict[str, Any]: ) return compressed_state + @cached_property def as_compressed_state_json(self) -> str: """Build a compressed JSON key value pair of a state for adds. @@ -1338,11 +1344,7 @@ def as_compressed_state_json(self) -> str: It is used for sending multiple states in a single message. """ - if not self._as_compressed_state_json: - self._as_compressed_state_json = json_dumps( - {self.entity_id: self.as_compressed_state()} - )[1:-1] - return self._as_compressed_state_json + return json_dumps({self.entity_id: self.as_compressed_state})[1:-1] @classmethod def from_dict(cls, json_dict: dict[str, Any]) -> Self | None: @@ -1405,14 +1407,59 @@ def __repr__(self) -> str: ) +class States(UserDict[str, State]): + """Container for states, maps entity_id -> State. + + Maintains an additional index: + - domain -> dict[str, State] + """ + + def __init__(self) -> None: + """Initialize the container.""" + super().__init__() + self._domain_index: defaultdict[str, dict[str, State]] = defaultdict(dict) + + def values(self) -> ValuesView[State]: + """Return the underlying values to avoid __iter__ overhead.""" + return self.data.values() + + def __setitem__(self, key: str, entry: State) -> None: + """Add an item.""" + self.data[key] = entry + self._domain_index[entry.domain][entry.entity_id] = entry + + def __delitem__(self, key: str) -> None: + """Remove an item.""" + entry = self[key] + del self._domain_index[entry.domain][entry.entity_id] + super().__delitem__(key) + + def domain_entity_ids(self, key: str) -> KeysView[str] | tuple[()]: + """Get all entity_ids for a domain.""" + # Avoid polluting _domain_index with non-existing domains + if key not in self._domain_index: + return () + return self._domain_index[key].keys() + + def domain_states(self, key: str) -> ValuesView[State] | tuple[()]: + """Get all states for a domain.""" + # Avoid polluting _domain_index with non-existing domains + if key not in self._domain_index: + return () + return self._domain_index[key].values() + + class StateMachine: """Helper class that tracks the state of different entities.""" - __slots__ = ("_states", "_reservations", "_bus", "_loop") + __slots__ = ("_states", "_states_data", "_reservations", "_bus", "_loop") def __init__(self, bus: EventBus, loop: asyncio.events.AbstractEventLoop) -> None: """Initialize state machine.""" - self._states: dict[str, State] = {} + self._states = States() + # _states_data is used to access the States backing dict directly to speed + # up read operations + self._states_data = self._states.data self._reservations: set[str] = set() self._bus = bus self._loop = loop @@ -1433,16 +1480,15 @@ def async_entity_ids( This method must be run in the event loop. """ if domain_filter is None: - return list(self._states) + return list(self._states_data) if isinstance(domain_filter, str): - domain_filter = (domain_filter.lower(),) + return list(self._states.domain_entity_ids(domain_filter.lower())) - return [ - state.entity_id - for state in self._states.values() - if state.domain in domain_filter - ] + entity_ids: list[str] = [] + for domain in domain_filter: + entity_ids.extend(self._states.domain_entity_ids(domain)) + return entity_ids @callback def async_entity_ids_count( @@ -1453,13 +1499,13 @@ def async_entity_ids_count( This method must be run in the event loop. """ if domain_filter is None: - return len(self._states) + return len(self._states_data) if isinstance(domain_filter, str): - domain_filter = (domain_filter.lower(),) + return len(self._states.domain_entity_ids(domain_filter.lower())) - return len( - [None for state in self._states.values() if state.domain in domain_filter] + return sum( + len(self._states.domain_entity_ids(domain)) for domain in domain_filter ) def all(self, domain_filter: str | Iterable[str] | None = None) -> list[State]: @@ -1477,21 +1523,22 @@ def async_all( This method must be run in the event loop. """ if domain_filter is None: - return list(self._states.values()) + return list(self._states_data.values()) if isinstance(domain_filter, str): - domain_filter = (domain_filter.lower(),) + return list(self._states.domain_states(domain_filter.lower())) - return [ - state for state in self._states.values() if state.domain in domain_filter - ] + states: list[State] = [] + for domain in domain_filter: + states.extend(self._states.domain_states(domain)) + return states def get(self, entity_id: str) -> State | None: """Retrieve state of entity_id or None if not found. Async friendly. """ - return self._states.get(entity_id.lower()) + return self._states_data.get(entity_id.lower()) def is_state(self, entity_id: str, state: str) -> bool: """Test if entity exists and is in specified state. @@ -1520,9 +1567,7 @@ def async_remove(self, entity_id: str, context: Context | None = None) -> bool: """ entity_id = entity_id.lower() old_state = self._states.pop(entity_id, None) - - if entity_id in self._reservations: - self._reservations.remove(entity_id) + self._reservations.discard(entity_id) if old_state is None: return False @@ -1571,7 +1616,7 @@ def async_reserve(self, entity_id: str) -> None: entity_id are added. """ entity_id = entity_id.lower() - if entity_id in self._states or entity_id in self._reservations: + if entity_id in self._states_data or entity_id in self._reservations: raise HomeAssistantError( "async_reserve must not be called once the state is in the state" " machine." @@ -1583,7 +1628,9 @@ def async_reserve(self, entity_id: str) -> None: def async_available(self, entity_id: str) -> bool: """Check to see if an entity_id is available to be used.""" entity_id = entity_id.lower() - return entity_id not in self._states and entity_id not in self._reservations + return ( + entity_id not in self._states_data and entity_id not in self._reservations + ) @callback def async_set( @@ -1593,6 +1640,7 @@ def async_set( attributes: Mapping[str, Any] | None = None, force_update: bool = False, context: Context | None = None, + state_info: StateInfo | None = None, ) -> None: """Set the state of an entity, add entity if it does not exist. @@ -1606,7 +1654,7 @@ def async_set( entity_id = entity_id.lower() new_state = str(new_state) attributes = attributes or {} - if (old_state := self._states.get(entity_id)) is None: + if (old_state := self._states_data.get(entity_id)) is None: same_state = False same_attr = False last_changed = None @@ -1644,6 +1692,7 @@ def async_set( now, context, old_state is None, + state_info, ) if old_state is not None: old_state.expire() diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index 467fc3b522886b..63cbfda5b9bd5a 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -138,8 +138,8 @@ def __init__( self.hass = hass self._preview: set[str] = set() self._progress: dict[str, FlowHandler] = {} - self._handler_progress_index: dict[str, set[str]] = {} - self._init_data_process_index: dict[type, set[str]] = {} + self._handler_progress_index: dict[str, set[FlowHandler]] = {} + self._init_data_process_index: dict[type, set[FlowHandler]] = {} @abc.abstractmethod async def async_create_flow( @@ -221,9 +221,9 @@ def async_progress_by_init_data_type( """Return flows in progress init matching by data type as a partial FlowResult.""" return _async_flow_handler_to_flow_result( ( - self._progress[flow_id] - for flow_id in self._init_data_process_index.get(init_data_type, {}) - if matcher(self._progress[flow_id].init_data) + progress + for progress in self._init_data_process_index.get(init_data_type, set()) + if matcher(progress.init_data) ), include_uninitialized, ) @@ -237,18 +237,13 @@ def _async_progress_by_handler( If match_context is specified, only return flows with a context that is a superset of match_context. """ - match_context_items = match_context.items() if match_context else None + if not match_context: + return list(self._handler_progress_index.get(handler, [])) + match_context_items = match_context.items() return [ progress - for flow_id in self._handler_progress_index.get(handler, {}) - if (progress := self._progress[flow_id]) - and ( - not match_context_items - or ( - (context := progress.context) - and match_context_items <= context.items() - ) - ) + for progress in self._handler_progress_index.get(handler, set()) + if match_context_items <= progress.context.items() ] async def async_init( @@ -348,22 +343,20 @@ def _async_add_flow_progress(self, flow: FlowHandler) -> None: """Add a flow to in progress.""" if flow.init_data is not None: init_data_type = type(flow.init_data) - self._init_data_process_index.setdefault(init_data_type, set()).add( - flow.flow_id - ) + self._init_data_process_index.setdefault(init_data_type, set()).add(flow) self._progress[flow.flow_id] = flow - self._handler_progress_index.setdefault(flow.handler, set()).add(flow.flow_id) + self._handler_progress_index.setdefault(flow.handler, set()).add(flow) @callback def _async_remove_flow_from_index(self, flow: FlowHandler) -> None: """Remove a flow from in progress.""" if flow.init_data is not None: init_data_type = type(flow.init_data) - self._init_data_process_index[init_data_type].remove(flow.flow_id) + self._init_data_process_index[init_data_type].remove(flow) if not self._init_data_process_index[init_data_type]: del self._init_data_process_index[init_data_type] handler = flow.handler - self._handler_progress_index[handler].remove(flow.flow_id) + self._handler_progress_index[handler].remove(flow) if not self._handler_progress_index[handler]: del self._handler_progress_index[handler] diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index 7b0aa78d69e06f..5784667bc675d6 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -213,6 +213,10 @@ ], "manufacturer_id": 76, }, + { + "domain": "idasen_desk", + "service_uuid": "99fa0001-338a-1024-8a49-009c0215f78a", + }, { "connectable": False, "domain": "inkbird", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 7d84dc87cbe047..54089723e21809 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -114,6 +114,7 @@ "eafm", "easyenergy", "ecobee", + "ecoforest", "econet", "ecowitt", "edl21", @@ -210,6 +211,7 @@ "iaqualink", "ibeacon", "icloud", + "idasen_desk", "ifttt", "imap", "inkbird", @@ -301,6 +303,7 @@ "netatmo", "netgear", "nexia", + "nextbus", "nextcloud", "nextdns", "nfandroidtv", @@ -351,6 +354,7 @@ "point", "poolsense", "powerwall", + "private_ble_device", "profiler", "progettihwsw", "prosegur", @@ -454,6 +458,7 @@ "surepetcare", "switchbee", "switchbot", + "switchbot_cloud", "switcher_kis", "syncthing", "syncthru", @@ -472,6 +477,7 @@ "tibber", "tile", "tilt_ble", + "todoist", "tolo", "tomorrowio", "toon", @@ -515,8 +521,10 @@ "volvooncall", "vulcan", "wallbox", + "waqi", "watttime", "waze_travel_time", + "weatherkit", "webostv", "wemo", "whirlpool", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index c357b5aed4c9aa..aac00cdd0d8eaa 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -335,6 +335,12 @@ "config_flow": false, "iot_class": "local_polling", "name": "Apple iTunes" + }, + "weatherkit": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling", + "name": "Apple WeatherKit" } } }, @@ -857,7 +863,7 @@ }, "co2signal": { "name": "Electricity Maps", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling" }, @@ -1305,6 +1311,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "ecoforest": { + "name": "Ecoforest", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "econet": { "name": "Rheem EcoNet Products", "integration_type": "hub", @@ -1475,6 +1487,11 @@ "config_flow": false, "iot_class": "local_polling" }, + "enmax": { + "name": "Enmax Energy", + "integration_type": "virtual", + "supported_by": "opower" + }, "enocean": { "name": "EnOcean", "integration_type": "hub", @@ -2572,6 +2589,12 @@ "config_flow": true, "iot_class": "local_polling", "name": "IKEA TR\u00c5DFRI" + }, + "idasen_desk": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push", + "name": "IKEA Idasen Desk" } } }, @@ -3706,9 +3729,8 @@ "supported_by": "overkiz" }, "nextbus": { - "name": "NextBus", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "cloud_polling" }, "nextcloud": { @@ -4320,6 +4342,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "private_ble_device": { + "name": "Private BLE Device", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push" + }, "profiler": { "name": "Profiler", "integration_type": "hub", @@ -5502,9 +5530,20 @@ }, "switchbot": { "name": "SwitchBot", - "integration_type": "hub", - "config_flow": true, - "iot_class": "local_push" + "integrations": { + "switchbot": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push", + "name": "SwitchBot Bluetooth" + }, + "switchbot_cloud": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling", + "name": "SwitchBot Cloud" + } + } }, "switcher_kis": { "name": "Switcher", @@ -5796,7 +5835,7 @@ "todoist": { "name": "Todoist", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "cloud_polling" }, "tolo": { @@ -5930,7 +5969,7 @@ "name": "Trend", "integration_type": "hub", "config_flow": false, - "iot_class": "local_push" + "iot_class": "calculated" }, "tuya": { "name": "Tuya", @@ -6282,7 +6321,7 @@ "waqi": { "name": "World Air Quality Index (WAQI)", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "cloud_polling" }, "waterfurnace": { @@ -6775,6 +6814,7 @@ "mobile_app", "moehlenhoff_alpha2", "moon", + "nextbus", "nmap_tracker", "plant", "proximity", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 3874a06ab4b457..36ddfd68479915 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -44,7 +44,7 @@ "always_discover": True, "domain": "roku", }, - "EB-*": { + "EB": { "always_discover": True, "domain": "ecobee", }, @@ -386,6 +386,11 @@ "name": "wac*", }, ], + "_ecobee._tcp.local.": [ + { + "domain": "ecobee", + }, + ], "_elg._tcp.local.": [ { "domain": "elgato", diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index 9c2492d65e8fb1..064579a95d34e2 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -12,6 +12,7 @@ import attr from yarl import URL +from homeassistant.backports.functools import cached_property from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError @@ -211,7 +212,7 @@ def _validate_configuration_url(value: Any) -> str | None: return str(value) -@attr.s(slots=True, frozen=True) +@attr.s(frozen=True) class DeviceEntry: """Device Registry Entry.""" @@ -234,8 +235,6 @@ class DeviceEntry: # This value is not stored, just used to keep track of events to fire. is_new: bool = attr.ib(default=False) - _json_repr: str | None = attr.ib(cmp=False, default=None, init=False, repr=False) - @property def disabled(self) -> bool: """Return if entry is disabled.""" @@ -262,15 +261,12 @@ def dict_repr(self) -> dict[str, Any]: "via_device_id": self.via_device_id, } - @property + @cached_property def json_repr(self) -> str | None: """Return a cached JSON representation of the entry.""" - if self._json_repr is not None: - return self._json_repr - try: dict_repr = self.dict_repr - object.__setattr__(self, "_json_repr", JSON_DUMP(dict_repr)) + return JSON_DUMP(dict_repr) except (ValueError, TypeError): _LOGGER.error( "Unable to serialize entry %s to JSON. Bad data found at %s", @@ -279,7 +275,7 @@ def json_repr(self) -> str | None: find_paths_unserializable_data(dict_repr, dump=JSON_DUMP) ), ) - return self._json_repr + return None @attr.s(slots=True, frozen=True) @@ -392,14 +388,14 @@ def values(self) -> ValuesView[_EntryTypeT]: def __setitem__(self, key: str, entry: _EntryTypeT) -> None: """Add an item.""" - if key in self: - old_entry = self[key] + data = self.data + if key in data: + old_entry = data[key] for connection in old_entry.connections: del self._connections[connection] for identifier in old_entry.identifiers: del self._identifiers[identifier] - # type ignore linked to mypy issue: https://github.com/python/mypy/issues/13596 - super().__setitem__(key, entry) # type: ignore[assignment] + data[key] = entry for connection in entry.connections: self._connections[connection] = entry for identifier in entry.identifiers: diff --git a/homeassistant/helpers/discovery_flow.py b/homeassistant/helpers/discovery_flow.py index 586824b4495fc5..306e8b51d63abb 100644 --- a/homeassistant/helpers/discovery_flow.py +++ b/homeassistant/helpers/discovery_flow.py @@ -44,8 +44,9 @@ def _async_init_flow( # as ones in progress as it may cause additional device probing # which can overload devices since zeroconf/ssdp updates can happen # multiple times in the same minute - if hass.is_stopping or hass.config_entries.flow.async_has_matching_flow( - domain, context, data + if ( + hass.config_entries.flow.async_has_matching_flow(domain, context, data) + or hass.is_stopping ): return None diff --git a/homeassistant/helpers/dispatcher.py b/homeassistant/helpers/dispatcher.py index 60aab156144f66..e416d939914b69 100644 --- a/homeassistant/helpers/dispatcher.py +++ b/homeassistant/helpers/dispatcher.py @@ -2,6 +2,7 @@ from __future__ import annotations from collections.abc import Callable, Coroutine +from functools import partial import logging from typing import Any @@ -13,6 +14,14 @@ _LOGGER = logging.getLogger(__name__) DATA_DISPATCHER = "dispatcher" +_DispatcherDataType = dict[ + str, + dict[ + Callable[..., Any], + HassJob[..., None | Coroutine[Any, Any, None]] | None, + ], +] + @bind_hass def dispatcher_connect( @@ -30,6 +39,26 @@ def remove_dispatcher() -> None: return remove_dispatcher +@callback +def _async_remove_dispatcher( + dispatchers: _DispatcherDataType, + signal: str, + target: Callable[..., Any], +) -> None: + """Remove signal listener.""" + try: + signal_dispatchers = dispatchers[signal] + del signal_dispatchers[target] + # Cleanup the signal dict if it is now empty + # to prevent memory leaks + if not signal_dispatchers: + del dispatchers[signal] + except (KeyError, ValueError): + # KeyError is key target listener did not exist + # ValueError if listener did not exist within signal + _LOGGER.warning("Unable to remove unknown dispatcher %s", target) + + @callback @bind_hass def async_dispatcher_connect( @@ -41,19 +70,18 @@ def async_dispatcher_connect( """ if DATA_DISPATCHER not in hass.data: hass.data[DATA_DISPATCHER] = {} - hass.data[DATA_DISPATCHER].setdefault(signal, {})[target] = None - @callback - def async_remove_dispatcher() -> None: - """Remove signal listener.""" - try: - del hass.data[DATA_DISPATCHER][signal][target] - except (KeyError, ValueError): - # KeyError is key target listener did not exist - # ValueError if listener did not exist within signal - _LOGGER.warning("Unable to remove unknown dispatcher %s", target) + dispatchers: _DispatcherDataType = hass.data[DATA_DISPATCHER] + + if signal not in dispatchers: + dispatchers[signal] = {} - return async_remove_dispatcher + dispatchers[signal][target] = None + # Use a partial for the remove since it uses + # less memory than a full closure since a partial copies + # the body of the function and we don't have to store + # many different copies of the same function + return partial(_async_remove_dispatcher, dispatchers, signal, target) @bind_hass @@ -87,21 +115,14 @@ def async_dispatcher_send(hass: HomeAssistant, signal: str, *args: Any) -> None: This method must be run in the event loop. """ - target_list: dict[ - Callable[..., Any], HassJob[..., None | Coroutine[Any, Any, None]] | None - ] = hass.data.get(DATA_DISPATCHER, {}).get(signal, {}) + if (maybe_dispatchers := hass.data.get(DATA_DISPATCHER)) is None: + return + dispatchers: _DispatcherDataType = maybe_dispatchers + if (target_list := dispatchers.get(signal)) is None: + return - run: list[HassJob[..., None | Coroutine[Any, Any, None]]] = [] - for target, job in target_list.items(): + for target, job in list(target_list.items()): if job is None: job = _generate_job(signal, target) target_list[target] = job - - # Run the jobs all at the end - # to ensure no jobs add more dispatchers - # which can result in the target_list - # changing size during iteration - run.append(job) - - for job in run: hass.async_run_hass_job(job, *args) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 29a944874abe10..9b16b0c24fdd24 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -4,15 +4,25 @@ from abc import ABC import asyncio from collections.abc import Coroutine, Iterable, Mapping, MutableMapping +from contextlib import suppress from dataclasses import dataclass -from datetime import datetime, timedelta +from datetime import timedelta from enum import Enum, auto import functools as ft import logging import math import sys from timeit import default_timer as timer -from typing import TYPE_CHECKING, Any, Final, Literal, TypeVar, final +from typing import ( + TYPE_CHECKING, + Any, + Final, + Literal, + NotRequired, + TypedDict, + TypeVar, + final, +) import voluptuous as vol @@ -35,9 +45,17 @@ EntityCategory, ) from homeassistant.core import CALLBACK_TYPE, Context, HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError, NoEntitySpecifiedError -from homeassistant.loader import bind_hass -from homeassistant.util import dt as dt_util, ensure_unique_string, slugify +from homeassistant.exceptions import ( + HomeAssistantError, + InvalidStateError, + NoEntitySpecifiedError, +) +from homeassistant.loader import ( + IntegrationNotLoaded, + async_get_loaded_integration, + bind_hass, +) +from homeassistant.util import ensure_unique_string, slugify from . import device_registry as dr, entity_registry as er from .device_registry import DeviceInfo, EventDeviceRegistryUpdatedData @@ -56,8 +74,6 @@ _LOGGER = logging.getLogger(__name__) SLOW_UPDATE_WARNING = 10 DATA_ENTITY_SOURCE = "entity_info" -SOURCE_CONFIG_ENTRY = "config_entry" -SOURCE_PLATFORM_CONFIG = "platform_config" # Used when converting float states to string: limit precision according to machine # epsilon to make the string representation readable @@ -72,9 +88,9 @@ def async_setup(hass: HomeAssistant) -> None: @callback @bind_hass -def entity_sources(hass: HomeAssistant) -> dict[str, dict[str, str]]: +def entity_sources(hass: HomeAssistant) -> dict[str, EntityInfo]: """Get the entity sources.""" - _entity_sources: dict[str, dict[str, str]] = hass.data[DATA_ENTITY_SOURCE] + _entity_sources: dict[str, EntityInfo] = hass.data[DATA_ENTITY_SOURCE] return _entity_sources @@ -177,6 +193,20 @@ def get_unit_of_measurement(hass: HomeAssistant, entity_id: str) -> str | None: ENTITY_CATEGORIES_SCHEMA: Final = vol.Coerce(EntityCategory) +class EntityInfo(TypedDict): + """Entity info.""" + + domain: str + custom_component: bool + config_entry: NotRequired[str] + + +class StateInfo(TypedDict): + """State info.""" + + unrecorded_attributes: frozenset[str] + + class EntityPlatformState(Enum): """The platform state of an entity.""" @@ -268,11 +298,27 @@ class Entity(ABC): # Context _context: Context | None = None - _context_set: datetime | None = None + _context_set: float | None = None # If entity is added to an entity platform _platform_state = EntityPlatformState.NOT_ADDED + # Attributes to exclude from recording, only set by base components, e.g. light + _entity_component_unrecorded_attributes: frozenset[str] = frozenset() + # Additional integration specific attributes to exclude from recording, set by + # platforms, e.g. a derived class in hue.light + _unrecorded_attributes: frozenset[str] = frozenset() + # Union of _entity_component_unrecorded_attributes and _unrecorded_attributes, + # set automatically by __init_subclass__ + __combined_unrecorded_attributes: frozenset[str] = ( + _entity_component_unrecorded_attributes | _unrecorded_attributes + ) + + # StateInfo. Set by EntityPlatform by calling async_internal_added_to_hass + # While not purely typed, it makes typehinting more useful for us + # and removes the need for constant None checks or asserts. + _state_info: StateInfo = None # type: ignore[assignment] + # Entity Properties _attr_assumed_state: bool = False _attr_attribution: str | None = None @@ -297,6 +343,13 @@ class Entity(ABC): _attr_unique_id: str | None = None _attr_unit_of_measurement: str | None + def __init_subclass__(cls, **kwargs: Any) -> None: + """Initialize an Entity subclass.""" + super().__init_subclass__(**kwargs) + cls.__combined_unrecorded_attributes = ( + cls._entity_component_unrecorded_attributes | cls._unrecorded_attributes + ) + @property def should_poll(self) -> bool: """Return True if entity has to be polled for state. @@ -656,7 +709,7 @@ def enabled(self) -> bool: def async_set_context(self, context: Context) -> None: """Set the context the entity currently operates under.""" self._context = context - self._context_set = dt_util.utcnow() + self._context_set = self.hass.loop.time() async def async_update_ha_state(self, force_refresh: bool = False) -> None: """Update Home Assistant with current state of entity. @@ -843,12 +896,26 @@ def _async_write_ha_state(self) -> None: if ( self._context_set is not None - and dt_util.utcnow() - self._context_set > self.context_recent_time + and hass.loop.time() - self._context_set + > self.context_recent_time.total_seconds() ): self._context = None self._context_set = None - hass.states.async_set(entity_id, state, attr, self.force_update, self._context) + try: + hass.states.async_set( + entity_id, + state, + attr, + self.force_update, + self._context, + self._state_info, + ) + except InvalidStateError: + _LOGGER.exception("Failed to set state, fall back to %s", STATE_UNKNOWN) + hass.states.async_set( + entity_id, STATE_UNKNOWN, {}, self.force_update, self._context + ) def schedule_update_ha_state(self, force_refresh: bool = False) -> None: """Schedule an update ha state change task. @@ -1048,18 +1115,19 @@ async def async_internal_added_to_hass(self) -> None: Not to be extended by integrations. """ - info = { + entity_info: EntityInfo = { "domain": self.platform.platform_name, "custom_component": "custom_components" in type(self).__module__, } if self.platform.config_entry: - info["source"] = SOURCE_CONFIG_ENTRY - info["config_entry"] = self.platform.config_entry.entry_id - else: - info["source"] = SOURCE_PLATFORM_CONFIG + entity_info["config_entry"] = self.platform.config_entry.entry_id + + entity_sources(self.hass)[self.entity_id] = entity_info - self.hass.data[DATA_ENTITY_SOURCE][self.entity_id] = info + self._state_info = { + "unrecorded_attributes": self.__combined_unrecorded_attributes + } if self.registry_entry is not None: # This is an assert as it should never happen, but helps in tests @@ -1190,8 +1258,21 @@ async def async_request_call(self, coro: Coroutine[Any, Any, _T]) -> _T: def _suggest_report_issue(self) -> str: """Suggest to report an issue.""" report_issue = "" + + integration = None + # The check for self.platform guards against integrations not using an + # EntityComponent and can be removed in HA Core 2024.1 + if self.platform: + with suppress(IntegrationNotLoaded): + integration = async_get_loaded_integration( + self.hass, self.platform.platform_name + ) + if "custom_components" in type(self).__module__: - report_issue = "report it to the custom integration author." + if integration and integration.issue_tracker: + report_issue = f"create a bug report at {integration.issue_tracker}" + else: + report_issue = "report it to the custom integration author" else: report_issue = ( "create a bug report at " diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index ff2ca255279e84..42de4749215157 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -20,6 +20,7 @@ import attr import voluptuous as vol +from homeassistant.backports.functools import cached_property from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_FRIENDLY_NAME, @@ -148,7 +149,7 @@ def _protect_entity_options( return ReadOnlyDict({key: ReadOnlyDict(val) for key, val in data.items()}) -@attr.s(slots=True, frozen=True) +@attr.s(frozen=True) class RegistryEntry: """Entity Registry Entry.""" @@ -183,13 +184,6 @@ class RegistryEntry: translation_key: str | None = attr.ib(default=None) unit_of_measurement: str | None = attr.ib(default=None) - _partial_repr: str | None | UndefinedType = attr.ib( - cmp=False, default=UNDEFINED, init=False, repr=False - ) - _display_repr: str | None | UndefinedType = attr.ib( - cmp=False, default=UNDEFINED, init=False, repr=False - ) - @domain.default def _domain_default(self) -> str: """Compute domain value.""" @@ -231,21 +225,17 @@ def _as_display_dict(self) -> dict[str, Any] | None: display_dict["dp"] = precision return display_dict - @property + @cached_property def display_json_repr(self) -> str | None: """Return a cached partial JSON representation of the entry. This version only includes what's needed for display. """ - if self._display_repr is not UNDEFINED: - return self._display_repr - try: dict_repr = self._as_display_dict json_repr: str | None = JSON_DUMP(dict_repr) if dict_repr else None - object.__setattr__(self, "_display_repr", json_repr) + return json_repr except (ValueError, TypeError): - object.__setattr__(self, "_display_repr", None) _LOGGER.error( "Unable to serialize entry %s to JSON. Bad data found at %s", self.entity_id, @@ -253,8 +243,8 @@ def display_json_repr(self) -> str | None: find_paths_unserializable_data(dict_repr, dump=JSON_DUMP) ), ) - # Mypy doesn't understand the __setattr__ business - return self._display_repr # type: ignore[return-value] + + return None @property def as_partial_dict(self) -> dict[str, Any]: @@ -278,17 +268,13 @@ def as_partial_dict(self) -> dict[str, Any]: "unique_id": self.unique_id, } - @property + @cached_property def partial_json_repr(self) -> str | None: """Return a cached partial JSON representation of the entry.""" - if self._partial_repr is not UNDEFINED: - return self._partial_repr - try: dict_repr = self.as_partial_dict - object.__setattr__(self, "_partial_repr", JSON_DUMP(dict_repr)) + return JSON_DUMP(dict_repr) except (ValueError, TypeError): - object.__setattr__(self, "_partial_repr", None) _LOGGER.error( "Unable to serialize entry %s to JSON. Bad data found at %s", self.entity_id, @@ -296,8 +282,7 @@ def partial_json_repr(self) -> str | None: find_paths_unserializable_data(dict_repr, dump=JSON_DUMP) ), ) - # Mypy doesn't understand the __setattr__ business - return self._partial_repr # type: ignore[return-value] + return None @callback def write_unavailable_state(self, hass: HomeAssistant) -> None: @@ -430,7 +415,7 @@ async def _async_migrate_func( return data -class EntityRegistryItems(UserDict[str, "RegistryEntry"]): +class EntityRegistryItems(UserDict[str, RegistryEntry]): """Container for entity registry items, maps entity_id -> entry. Maintains two additional indexes: @@ -450,11 +435,12 @@ def values(self) -> ValuesView[RegistryEntry]: def __setitem__(self, key: str, entry: RegistryEntry) -> None: """Add an item.""" - if key in self: - old_entry = self[key] + data = self.data + if key in data: + old_entry = data[key] del self._entry_ids[old_entry.id] del self._index[(old_entry.domain, old_entry.platform, old_entry.unique_id)] - super().__setitem__(key, entry) + data[key] = entry self._entry_ids[entry.id] = entry self._index[(entry.domain, entry.platform, entry.unique_id)] = entry.entity_id diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index 62a3b91991d9d1..2da8a48be987ee 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -915,7 +915,11 @@ def __repr__(self) -> str: """Return the representation.""" return f"" - def async_setup(self, raise_on_template_error: bool, strict: bool = False) -> None: + def async_setup( + self, + strict: bool = False, + log_fn: Callable[[int, str], None] | None = None, + ) -> None: """Activation of template tracking.""" block_render = False super_template = self._track_templates[0] if self._has_super_template else None @@ -925,7 +929,7 @@ def async_setup(self, raise_on_template_error: bool, strict: bool = False) -> No template = super_template.template variables = super_template.variables self._info[template] = info = template.async_render_to_info( - variables, strict=strict + variables, strict=strict, log_fn=log_fn ) # If the super template did not render to True, don't update other templates @@ -946,17 +950,18 @@ def async_setup(self, raise_on_template_error: bool, strict: bool = False) -> No template = track_template_.template variables = track_template_.variables self._info[template] = info = template.async_render_to_info( - variables, strict=strict + variables, strict=strict, log_fn=log_fn ) if info.exception: - if raise_on_template_error: - raise info.exception - _LOGGER.error( - "Error while processing template: %s", - track_template_.template, - exc_info=info.exception, - ) + if not log_fn: + _LOGGER.error( + "Error while processing template: %s", + track_template_.template, + exc_info=info.exception, + ) + else: + log_fn(logging.ERROR, str(info.exception)) self._track_state_changes = async_track_state_change_filtered( self.hass, _render_infos_to_track_states(self._info.values()), self._refresh @@ -1189,7 +1194,7 @@ def _apply_update( ) _LOGGER.debug( ( - "Template group %s listens for %s, re-render blocker by super" + "Template group %s listens for %s, re-render blocked by super" " template: %s" ), self._track_templates, @@ -1231,8 +1236,8 @@ def async_track_template_result( hass: HomeAssistant, track_templates: Sequence[TrackTemplate], action: TrackTemplateResultListener, - raise_on_template_error: bool = False, strict: bool = False, + log_fn: Callable[[int, str], None] | None = None, has_super_template: bool = False, ) -> TrackTemplateResultInfo: """Add a listener that fires when the result of a template changes. @@ -1257,13 +1262,11 @@ def async_track_template_result( An iterable of TrackTemplate. action Callable to call with results. - raise_on_template_error - When set to True, if there is an exception - processing the template during setup, the system - will raise the exception instead of setting up - tracking. strict When set to True, raise on undefined variables. + log_fn + If not None, template error messages will logging by calling log_fn + instead of the normal logging facility. has_super_template When set to True, the first template will block rendering of other templates if it doesn't render as True. @@ -1274,7 +1277,7 @@ def async_track_template_result( """ tracker = TrackTemplateResultInfo(hass, track_templates, action, has_super_template) - tracker.async_setup(raise_on_template_error, strict=strict) + tracker.async_setup(strict=strict, log_fn=log_fn) return tracker @@ -1432,6 +1435,13 @@ def unsub_point_in_time_listener() -> None: track_point_in_utc_time = threaded_listener_factory(async_track_point_in_utc_time) +def _run_async_call_action( + hass: HomeAssistant, job: HassJob[[datetime], Coroutine[Any, Any, None] | None] +) -> None: + """Run action.""" + hass.async_run_hass_job(job, time_tracker_utcnow()) + + @callback @bind_hass def async_call_at( @@ -1441,26 +1451,12 @@ def async_call_at( loop_time: float, ) -> CALLBACK_TYPE: """Add a listener that is called at .""" - - @callback - def run_action(job: HassJob[[datetime], Coroutine[Any, Any, None] | None]) -> None: - """Call the action.""" - hass.async_run_hass_job(job, time_tracker_utcnow()) - job = ( action if isinstance(action, HassJob) else HassJob(action, f"call_at {loop_time}") ) - cancel_callback = hass.loop.call_at(loop_time, run_action, job) - - @callback - def unsub_call_later_listener() -> None: - """Cancel the call_later.""" - assert cancel_callback is not None - cancel_callback.cancel() - - return unsub_call_later_listener + return hass.loop.call_at(loop_time, _run_async_call_action, hass, job).cancel @callback @@ -1474,26 +1470,13 @@ def async_call_later( """Add a listener that is called in .""" if isinstance(delay, timedelta): delay = delay.total_seconds() - - @callback - def run_action(job: HassJob[[datetime], Coroutine[Any, Any, None] | None]) -> None: - """Call the action.""" - hass.async_run_hass_job(job, time_tracker_utcnow()) - job = ( action if isinstance(action, HassJob) else HassJob(action, f"call_later {delay}") ) - cancel_callback = hass.loop.call_at(hass.loop.time() + delay, run_action, job) - - @callback - def unsub_call_later_listener() -> None: - """Cancel the call_later.""" - assert cancel_callback is not None - cancel_callback.cancel() - - return unsub_call_later_listener + loop = hass.loop + return loop.call_at(loop.time() + delay, _run_async_call_action, hass, job).cancel call_later = threaded_listener_factory(async_call_later) diff --git a/homeassistant/helpers/integration_platform.py b/homeassistant/helpers/integration_platform.py index ddaede44962771..0a9a6efd525648 100644 --- a/homeassistant/helpers/integration_platform.py +++ b/homeassistant/helpers/integration_platform.py @@ -65,23 +65,10 @@ async def _async_process_single_integration_platform_component( ) -async def async_process_integration_platform_for_component( +async def _async_process_integration_platform_for_component( hass: HomeAssistant, component_name: str ) -> None: - """Process integration platforms on demand for a component. - - This function will load the integration platforms - for an integration instead of waiting for the EVENT_COMPONENT_LOADED - event to be fired for the integration. - - When the integration will create entities before - it has finished setting up; call this function to ensure - that the integration platforms are loaded before the entities - are created. - """ - if DATA_INTEGRATION_PLATFORMS not in hass.data: - # There are no integration platforms loaded yet - return + """Process integration platforms for a component.""" integration_platforms: list[IntegrationPlatform] = hass.data[ DATA_INTEGRATION_PLATFORMS ] @@ -116,7 +103,7 @@ async def async_process_integration_platforms( async def _async_component_loaded(event: Event) -> None: """Handle a new component loaded.""" - await async_process_integration_platform_for_component( + await _async_process_integration_platform_for_component( hass, event.data[ATTR_COMPONENT] ) diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index 4035d55b3258c2..a1d045eb542d39 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -6,6 +6,7 @@ from contextlib import asynccontextmanager, suppress from contextvars import ContextVar from copy import copy +from dataclasses import dataclass from datetime import datetime, timedelta from functools import partial import itertools @@ -401,7 +402,7 @@ def _step_log(self, default_message, timeout=None): ) self._log("Executing step %s%s", self._script.last_action, _timeout) - async def async_run(self) -> ServiceResponse: + async def async_run(self) -> ScriptRunResult | None: """Run script.""" # Push the script to the script execution stack if (script_stack := script_stack_cv.get()) is None: @@ -443,7 +444,7 @@ async def async_run(self) -> ServiceResponse: script_stack.pop() self._finish() - return response + return ScriptRunResult(response, self._variables) async def _async_step(self, log_exceptions): continue_on_error = self._action.get(CONF_CONTINUE_ON_ERROR, False) @@ -910,7 +911,7 @@ async def async_run_sequence(iteration, extra_msg=""): async def _async_choose_step(self) -> None: """Choose a sequence.""" - # pylint: disable=protected-access + # pylint: disable-next=protected-access choose_data = await self._script._async_get_choose_data(self._step) with trace_path("choose"): @@ -932,7 +933,7 @@ async def _async_choose_step(self) -> None: async def _async_if_step(self) -> None: """If sequence.""" - # pylint: disable=protected-access + # pylint: disable-next=protected-access if_data = await self._script._async_get_if_data(self._step) test_conditions = False @@ -1046,7 +1047,7 @@ async def _async_stop_step(self): @async_trace_path("parallel") async def _async_parallel_step(self) -> None: """Run a sequence in parallel.""" - # pylint: disable=protected-access + # pylint: disable-next=protected-access scripts = await self._script._async_get_parallel_scripts(self._step) async def async_run_with_trace(idx: int, script: Script) -> None: @@ -1106,9 +1107,8 @@ async def async_run(self) -> None: await super().async_run() def _finish(self) -> None: - # pylint: disable=protected-access if self.lock_acquired: - self._script._queue_lck.release() + self._script._queue_lck.release() # pylint: disable=protected-access self.lock_acquired = False super()._finish() @@ -1189,6 +1189,14 @@ class _IfData(TypedDict): if_else: Script | None +@dataclass +class ScriptRunResult: + """Container with the result of a script run.""" + + service_response: ServiceResponse + variables: dict + + class Script: """Representation of a script.""" @@ -1480,7 +1488,7 @@ async def async_run( run_variables: _VarsType | None = None, context: Context | None = None, started_action: Callable[..., Any] | None = None, - ) -> ServiceResponse: + ) -> ScriptRunResult | None: """Run script.""" if context is None: self._log( diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 3eb537f96497d0..4532e1a00ae702 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -73,7 +73,7 @@ @cache def _base_components() -> dict[str, ModuleType]: """Return a cached lookup of base components.""" - # pylint: disable=import-outside-toplevel + # pylint: disable-next=import-outside-toplevel from homeassistant.components import ( alarm_control_panel, calendar, @@ -732,8 +732,59 @@ def async_set_service_schema( descriptions_cache[(domain, service)] = description +def _get_permissible_entity_candidates( + call: ServiceCall, + platforms: Iterable[EntityPlatform], + entity_perms: None | (Callable[[str, str], bool]), + target_all_entities: bool, + all_referenced: set[str] | None, +) -> list[Entity]: + """Get entity candidates that the user is allowed to access.""" + if entity_perms is not None: + # Check the permissions since entity_perms is set + if target_all_entities: + # If we target all entities, we will select all entities the user + # is allowed to control. + return [ + entity + for platform in platforms + for entity in platform.entities.values() + if entity_perms(entity.entity_id, POLICY_CONTROL) + ] + + assert all_referenced is not None + # If they reference specific entities, we will check if they are all + # allowed to be controlled. + for entity_id in all_referenced: + if not entity_perms(entity_id, POLICY_CONTROL): + raise Unauthorized( + context=call.context, + entity_id=entity_id, + permission=POLICY_CONTROL, + ) + + elif target_all_entities: + return [ + entity for platform in platforms for entity in platform.entities.values() + ] + + # We have already validated they have permissions to control all_referenced + # entities so we do not need to check again. + assert all_referenced is not None + if single_entity := len(all_referenced) == 1 and list(all_referenced)[0]: + for platform in platforms: + if (entity := platform.entities.get(single_entity)) is not None: + return [entity] + + return [ + platform.entities[entity_id] + for platform in platforms + for entity_id in all_referenced.intersection(platform.entities) + ] + + @bind_hass -async def entity_service_call( # noqa: C901 +async def entity_service_call( hass: HomeAssistant, platforms: Iterable[EntityPlatform], func: str | Callable[..., Coroutine[Any, Any, ServiceResponse]], @@ -771,69 +822,24 @@ async def entity_service_call( # noqa: C901 else: data = call - # Check the permissions - # A list with entities to call the service on. - entity_candidates: list[Entity] = [] - - if entity_perms is None: - for platform in platforms: - platform_entities = platform.entities - if target_all_entities: - entity_candidates.extend(platform_entities.values()) - else: - assert all_referenced is not None - entity_candidates.extend( - [ - platform_entities[entity_id] - for entity_id in all_referenced.intersection(platform_entities) - ] - ) - - elif target_all_entities: - # If we target all entities, we will select all entities the user - # is allowed to control. - for platform in platforms: - entity_candidates.extend( - [ - entity - for entity in platform.entities.values() - if entity_perms(entity.entity_id, POLICY_CONTROL) - ] - ) - - else: - assert all_referenced is not None - - for platform in platforms: - platform_entities = platform.entities - platform_entity_candidates = [] - entity_id_matches = all_referenced.intersection(platform_entities) - for entity_id in entity_id_matches: - if not entity_perms(entity_id, POLICY_CONTROL): - raise Unauthorized( - context=call.context, - entity_id=entity_id, - permission=POLICY_CONTROL, - ) - - platform_entity_candidates.append(platform_entities[entity_id]) - - entity_candidates.extend(platform_entity_candidates) + entity_candidates = _get_permissible_entity_candidates( + call, + platforms, + entity_perms, + target_all_entities, + all_referenced, + ) if not target_all_entities: assert referenced is not None - # Only report on explicit referenced entities - missing = set(referenced.referenced) - + missing = referenced.referenced.copy() for entity in entity_candidates: missing.discard(entity.entity_id) - referenced.log_missing(missing) entities: list[Entity] = [] - for entity in entity_candidates: if not entity.available: continue diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 40d64ba37aead7..b0754c13c7c503 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -458,6 +458,7 @@ class Template: "_exc_info", "_limited", "_strict", + "_log_fn", "_hash_cache", "_renders", ) @@ -475,6 +476,7 @@ def __init__(self, template: str, hass: HomeAssistant | None = None) -> None: self._exc_info: sys._OptExcInfo | None = None self._limited: bool | None = None self._strict: bool | None = None + self._log_fn: Callable[[int, str], None] | None = None self._hash_cache: int = hash(self.template) self._renders: int = 0 @@ -482,6 +484,11 @@ def __init__(self, template: str, hass: HomeAssistant | None = None) -> None: def _env(self) -> TemplateEnvironment: if self.hass is None: return _NO_HASS_ENV + # Bypass cache if a custom log function is specified + if self._log_fn is not None: + return TemplateEnvironment( + self.hass, self._limited, self._strict, self._log_fn + ) if self._limited: wanted_env = _ENVIRONMENT_LIMITED elif self._strict: @@ -491,9 +498,7 @@ def _env(self) -> TemplateEnvironment: ret: TemplateEnvironment | None = self.hass.data.get(wanted_env) if ret is None: ret = self.hass.data[wanted_env] = TemplateEnvironment( - self.hass, - self._limited, # type: ignore[no-untyped-call] - self._strict, + self.hass, self._limited, self._strict, self._log_fn ) return ret @@ -537,6 +542,7 @@ def async_render( parse_result: bool = True, limited: bool = False, strict: bool = False, + log_fn: Callable[[int, str], None] | None = None, **kwargs: Any, ) -> Any: """Render given template. @@ -553,7 +559,7 @@ def async_render( return self.template return self._parse_result(self.template) - compiled = self._compiled or self._ensure_compiled(limited, strict) + compiled = self._compiled or self._ensure_compiled(limited, strict, log_fn) if variables is not None: kwargs.update(variables) @@ -608,6 +614,7 @@ async def async_render_will_timeout( timeout: float, variables: TemplateVarsType = None, strict: bool = False, + log_fn: Callable[[int, str], None] | None = None, **kwargs: Any, ) -> bool: """Check to see if rendering a template will timeout during render. @@ -628,7 +635,7 @@ async def async_render_will_timeout( if self.is_static: return False - compiled = self._compiled or self._ensure_compiled(strict=strict) + compiled = self._compiled or self._ensure_compiled(strict=strict, log_fn=log_fn) if variables is not None: kwargs.update(variables) @@ -664,7 +671,11 @@ def _render_template() -> None: @callback def async_render_to_info( - self, variables: TemplateVarsType = None, strict: bool = False, **kwargs: Any + self, + variables: TemplateVarsType = None, + strict: bool = False, + log_fn: Callable[[int, str], None] | None = None, + **kwargs: Any, ) -> RenderInfo: """Render the template and collect an entity filter.""" self._renders += 1 @@ -680,7 +691,9 @@ def async_render_to_info( token = _render_info.set(render_info) try: - render_info._result = self.async_render(variables, strict=strict, **kwargs) + render_info._result = self.async_render( + variables, strict=strict, log_fn=log_fn, **kwargs + ) except TemplateError as ex: render_info.exception = ex finally: @@ -743,7 +756,10 @@ def async_render_with_possible_json_value( return value if error_value is _SENTINEL else error_value def _ensure_compiled( - self, limited: bool = False, strict: bool = False + self, + limited: bool = False, + strict: bool = False, + log_fn: Callable[[int, str], None] | None = None, ) -> jinja2.Template: """Bind a template to a specific hass instance.""" self.ensure_valid() @@ -756,10 +772,14 @@ def _ensure_compiled( self._strict is None or self._strict == strict ), "can't change between strict and non strict template" assert not (strict and limited), "can't combine strict and limited template" + assert ( + self._log_fn is None or self._log_fn == log_fn + ), "can't change custom log function" assert self._compiled_code is not None, "template code was not compiled" self._limited = limited self._strict = strict + self._log_fn = log_fn env = self._env self._compiled = jinja2.Template.from_code( @@ -909,15 +929,13 @@ def __repr__(self) -> str: class TemplateStateBase(State): """Class to represent a state object in a template.""" - __slots__ = ("_hass", "_collect", "_entity_id", "__dict__") - _state: State __setitem__ = _readonly __delitem__ = _readonly # Inheritance is done so functions that check against State keep working - # pylint: disable=super-init-not-called + # pylint: disable-next=super-init-not-called def __init__(self, hass: HomeAssistant, collect: bool, entity_id: str) -> None: """Initialize template state.""" self._hass = hass @@ -2178,45 +2196,56 @@ def _render_with_context( return template.render(**kwargs) -class LoggingUndefined(jinja2.Undefined): +def make_logging_undefined( + strict: bool | None, log_fn: Callable[[int, str], None] | None +) -> type[jinja2.Undefined]: """Log on undefined variables.""" - def _log_message(self) -> None: + if strict: + return jinja2.StrictUndefined + + def _log_with_logger(level: int, msg: str) -> None: template, action = template_cv.get() or ("", "rendering or compiling") - _LOGGER.warning( - "Template variable warning: %s when %s '%s'", - self._undefined_message, + _LOGGER.log( + level, + "Template variable %s: %s when %s '%s'", + logging.getLevelName(level).lower(), + msg, action, template, ) - def _fail_with_undefined_error(self, *args, **kwargs): - try: - return super()._fail_with_undefined_error(*args, **kwargs) - except self._undefined_exception as ex: - template, action = template_cv.get() or ("", "rendering or compiling") - _LOGGER.error( - "Template variable error: %s when %s '%s'", - self._undefined_message, - action, - template, - ) - raise ex + _log_fn = log_fn or _log_with_logger - def __str__(self) -> str: - """Log undefined __str___.""" - self._log_message() - return super().__str__() + class LoggingUndefined(jinja2.Undefined): + """Log on undefined variables.""" + + def _log_message(self) -> None: + _log_fn(logging.WARNING, self._undefined_message) + + def _fail_with_undefined_error(self, *args, **kwargs): + try: + return super()._fail_with_undefined_error(*args, **kwargs) + except self._undefined_exception as ex: + _log_fn(logging.ERROR, self._undefined_message) + raise ex + + def __str__(self) -> str: + """Log undefined __str___.""" + self._log_message() + return super().__str__() + + def __iter__(self): + """Log undefined __iter___.""" + self._log_message() + return super().__iter__() - def __iter__(self): - """Log undefined __iter___.""" - self._log_message() - return super().__iter__() + def __bool__(self) -> bool: + """Log undefined __bool___.""" + self._log_message() + return super().__bool__() - def __bool__(self) -> bool: - """Log undefined __bool___.""" - self._log_message() - return super().__bool__() + return LoggingUndefined async def async_load_custom_templates(hass: HomeAssistant) -> None: @@ -2276,14 +2305,15 @@ def get_source( class TemplateEnvironment(ImmutableSandboxedEnvironment): """The Home Assistant template environment.""" - def __init__(self, hass, limited=False, strict=False): + def __init__( + self, + hass: HomeAssistant | None, + limited: bool | None = False, + strict: bool | None = False, + log_fn: Callable[[int, str], None] | None = None, + ) -> None: """Initialise template environment.""" - undefined: type[LoggingUndefined] | type[jinja2.StrictUndefined] - if not strict: - undefined = LoggingUndefined - else: - undefined = jinja2.StrictUndefined - super().__init__(undefined=undefined) + super().__init__(undefined=make_logging_undefined(strict, log_fn)) self.hass = hass self.template_cache: weakref.WeakValueDictionary[ str | jinja2.nodes.Template, CodeType | str | None @@ -2381,6 +2411,10 @@ def __init__(self, hass, limited=False, strict=False): # can be discarded, we only need to get at the hass object. def hassfunction( func: Callable[Concatenate[HomeAssistant, _P], _R], + jinja_context: Callable[ + [Callable[Concatenate[Any, _P], _R]], + Callable[Concatenate[Any, _P], _R], + ] = pass_context, ) -> Callable[Concatenate[Any, _P], _R]: """Wrap function that depend on hass.""" @@ -2388,42 +2422,40 @@ def hassfunction( def wrapper(_: Any, *args: _P.args, **kwargs: _P.kwargs) -> _R: return func(hass, *args, **kwargs) - return pass_context(wrapper) + return jinja_context(wrapper) self.globals["device_entities"] = hassfunction(device_entities) - self.filters["device_entities"] = pass_context(self.globals["device_entities"]) + self.filters["device_entities"] = self.globals["device_entities"] self.globals["device_attr"] = hassfunction(device_attr) - self.filters["device_attr"] = pass_context(self.globals["device_attr"]) + self.filters["device_attr"] = self.globals["device_attr"] self.globals["is_device_attr"] = hassfunction(is_device_attr) - self.tests["is_device_attr"] = pass_eval_context(self.globals["is_device_attr"]) + self.tests["is_device_attr"] = hassfunction(is_device_attr, pass_eval_context) self.globals["config_entry_id"] = hassfunction(config_entry_id) - self.filters["config_entry_id"] = pass_context(self.globals["config_entry_id"]) + self.filters["config_entry_id"] = self.globals["config_entry_id"] self.globals["device_id"] = hassfunction(device_id) - self.filters["device_id"] = pass_context(self.globals["device_id"]) + self.filters["device_id"] = self.globals["device_id"] self.globals["areas"] = hassfunction(areas) - self.filters["areas"] = pass_context(self.globals["areas"]) + self.filters["areas"] = self.globals["areas"] self.globals["area_id"] = hassfunction(area_id) - self.filters["area_id"] = pass_context(self.globals["area_id"]) + self.filters["area_id"] = self.globals["area_id"] self.globals["area_name"] = hassfunction(area_name) - self.filters["area_name"] = pass_context(self.globals["area_name"]) + self.filters["area_name"] = self.globals["area_name"] self.globals["area_entities"] = hassfunction(area_entities) - self.filters["area_entities"] = pass_context(self.globals["area_entities"]) + self.filters["area_entities"] = self.globals["area_entities"] self.globals["area_devices"] = hassfunction(area_devices) - self.filters["area_devices"] = pass_context(self.globals["area_devices"]) + self.filters["area_devices"] = self.globals["area_devices"] self.globals["integration_entities"] = hassfunction(integration_entities) - self.filters["integration_entities"] = pass_context( - self.globals["integration_entities"] - ) + self.filters["integration_entities"] = self.globals["integration_entities"] if limited: # Only device_entities is available to limited templates, mark other @@ -2479,25 +2511,25 @@ def warn_unsupported(*args: Any, **kwargs: Any) -> NoReturn: return self.globals["expand"] = hassfunction(expand) - self.filters["expand"] = pass_context(self.globals["expand"]) + self.filters["expand"] = self.globals["expand"] self.globals["closest"] = hassfunction(closest) - self.filters["closest"] = pass_context(hassfunction(closest_filter)) + self.filters["closest"] = hassfunction(closest_filter) self.globals["distance"] = hassfunction(distance) self.globals["is_hidden_entity"] = hassfunction(is_hidden_entity) - self.tests["is_hidden_entity"] = pass_eval_context( - self.globals["is_hidden_entity"] + self.tests["is_hidden_entity"] = hassfunction( + is_hidden_entity, pass_eval_context ) self.globals["is_state"] = hassfunction(is_state) - self.tests["is_state"] = pass_eval_context(self.globals["is_state"]) + self.tests["is_state"] = hassfunction(is_state, pass_eval_context) self.globals["is_state_attr"] = hassfunction(is_state_attr) - self.tests["is_state_attr"] = pass_eval_context(self.globals["is_state_attr"]) + self.tests["is_state_attr"] = hassfunction(is_state_attr, pass_eval_context) self.globals["state_attr"] = hassfunction(state_attr) self.filters["state_attr"] = self.globals["state_attr"] self.globals["states"] = AllStates(hass) self.filters["states"] = self.globals["states"] self.globals["has_value"] = hassfunction(has_value) - self.filters["has_value"] = pass_context(self.globals["has_value"]) - self.tests["has_value"] = pass_eval_context(self.globals["has_value"]) + self.filters["has_value"] = self.globals["has_value"] + self.tests["has_value"] = hassfunction(has_value, pass_eval_context) self.globals["utcnow"] = hassfunction(utcnow) self.globals["now"] = hassfunction(now) self.globals["relative_time"] = hassfunction(relative_time) @@ -2575,4 +2607,4 @@ def compile( return cached -_NO_HASS_ENV = TemplateEnvironment(None) # type: ignore[no-untyped-call] +_NO_HASS_ENV = TemplateEnvironment(None) diff --git a/homeassistant/helpers/translation.py b/homeassistant/helpers/translation.py index 79ac3a0c5b73b2..41ad591d878994 100644 --- a/homeassistant/helpers/translation.py +++ b/homeassistant/helpers/translation.py @@ -190,6 +190,8 @@ async def _async_get_component_strings( class _TranslationCache: """Cache for flattened translations.""" + __slots__ = ("hass", "loaded", "cache") + def __init__(self, hass: HomeAssistant) -> None: """Initialize the cache.""" self.hass = hass diff --git a/homeassistant/helpers/trigger_template_entity.py b/homeassistant/helpers/trigger_template_entity.py index 8fc99f5cb52d46..bc7deceefefa74 100644 --- a/homeassistant/helpers/trigger_template_entity.py +++ b/homeassistant/helpers/trigger_template_entity.py @@ -77,8 +77,8 @@ class TriggerBaseEntity(Entity): """Template Base entity based on trigger data.""" domain: str - extra_template_keys: tuple | None = None - extra_template_keys_complex: tuple | None = None + extra_template_keys: tuple[str, ...] | None = None + extra_template_keys_complex: tuple[str, ...] | None = None _unique_id: str | None def __init__( @@ -94,7 +94,7 @@ def __init__( self._config = config self._static_rendered = {} - self._to_render_simple = [] + self._to_render_simple: list[str] = [] self._to_render_complex: list[str] = [] for itm in ( @@ -119,6 +119,7 @@ def __init__( # We make a copy so our initial render is 'unknown' and not 'unavailable' self._rendered = dict(self._static_rendered) self._parse_result = {CONF_AVAILABILITY} + self._attr_device_class = config.get(CONF_DEVICE_CLASS) @property def name(self) -> str | None: @@ -130,11 +131,6 @@ def unique_id(self) -> str | None: """Return unique ID of the entity.""" return self._unique_id - @property - def device_class(self): # type: ignore[no-untyped-def] - """Return device class of the entity.""" - return self._config.get(CONF_DEVICE_CLASS) - @property def icon(self) -> str | None: """Return icon.""" diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py index 34651fcaf9d1f4..2b570009a57d3e 100644 --- a/homeassistant/helpers/update_coordinator.py +++ b/homeassistant/helpers/update_coordinator.py @@ -81,7 +81,6 @@ def __init__( self._shutdown_requested = False self.config_entry = config_entries.current_entry.get() self.always_update = always_update - self._next_refresh: float | None = None # It's None before the first successful update. # Components should call async_config_entry_first_refresh @@ -184,7 +183,6 @@ def _unschedule_refresh(self) -> None: """Unschedule any pending refresh since there is no longer any listeners.""" self._async_unsub_refresh() self._debounced_refresh.async_cancel() - self._next_refresh = None def async_contexts(self) -> Generator[Any, None, None]: """Return all registered contexts.""" @@ -220,13 +218,13 @@ def _schedule_refresh(self) -> None: # We use event.async_call_at because DataUpdateCoordinator does # not need an exact update interval. now = self.hass.loop.time() - if self._next_refresh is None or self._next_refresh <= now: - self._next_refresh = int(now) + self._microsecond - self._next_refresh += self.update_interval.total_seconds() + + next_refresh = int(now) + self._microsecond + next_refresh += self.update_interval.total_seconds() self._unsub_refresh = event.async_call_at( self.hass, self._job, - self._next_refresh, + next_refresh, ) async def _handle_refresh_interval(self, _now: datetime) -> None: @@ -265,7 +263,6 @@ async def async_config_entry_first_refresh(self) -> None: async def async_refresh(self) -> None: """Refresh data and log errors.""" - self._next_refresh = None await self._async_refresh(log_failures=True) async def _async_refresh( # noqa: C901 @@ -405,7 +402,6 @@ def async_set_updated_data(self, data: _DataT) -> None: """Manually update data, notify listeners and reset refresh interval.""" self._async_unsub_refresh() self._debounced_refresh.async_cancel() - self._next_refresh = None self.data = data self.last_update_success = True diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 40161bd3be955f..9d4d6e880f89a5 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -25,6 +25,7 @@ import voluptuous as vol from . import generated +from .core import HomeAssistant, callback from .generated.application_credentials import APPLICATION_CREDENTIALS from .generated.bluetooth import BLUETOOTH from .generated.dhcp import DHCP @@ -37,7 +38,6 @@ # Typing imports that create a circular dependency if TYPE_CHECKING: from .config_entries import ConfigEntry - from .core import HomeAssistant from .helpers import device_registry as dr from .helpers.typing import ConfigType @@ -875,6 +875,22 @@ def _resolve_integrations_from_root( return integrations +@callback +def async_get_loaded_integration(hass: HomeAssistant, domain: str) -> Integration: + """Get an integration which is already loaded. + + Raises IntegrationNotLoaded if the integration is not loaded. + """ + cache = hass.data[DATA_INTEGRATIONS] + if TYPE_CHECKING: + cache = cast(dict[str, Integration | asyncio.Future[None]], cache) + int_or_fut = cache.get(domain, _UNDEF) + # Integration is never subclassed, so we can check for type + if type(int_or_fut) is Integration: # noqa: E721 + return int_or_fut + raise IntegrationNotLoaded(domain) + + async def async_get_integration(hass: HomeAssistant, domain: str) -> Integration: """Get integration.""" integrations_or_excs = await async_get_integrations(hass, [domain]) @@ -970,6 +986,15 @@ def __init__(self, domain: str) -> None: self.domain = domain +class IntegrationNotLoaded(LoaderError): + """Raised when a component is not loaded.""" + + def __init__(self, domain: str) -> None: + """Initialize a component not found error.""" + super().__init__(f"Integration '{domain}' not loaded.") + self.domain = domain + + class CircularDependency(LoaderError): """Raised when a circular dependency is found when resolving components.""" @@ -1162,3 +1187,8 @@ def _lookup_path(hass: HomeAssistant) -> list[str]: if hass.config.safe_mode: return [PACKAGE_BUILTIN] return [PACKAGE_CUSTOM_COMPONENTS, PACKAGE_BUILTIN] + + +def is_component_module_loaded(hass: HomeAssistant, module: str) -> bool: + """Test if a component module is loaded.""" + return module in hass.data[DATA_COMPONENTS] diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 19169de83f671c..6c65a08a97e97d 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -1,36 +1,36 @@ -aiodiscover==1.4.16 -aiohttp-cors==0.7.0 +aiodiscover==1.5.1 aiohttp==3.8.5 +aiohttp_cors==0.7.0 astral==2.2 async-timeout==4.0.3 -async-upnp-client==0.35.0 +async-upnp-client==0.35.1 atomicwrites-homeassistant==1.4.1 attrs==23.1.0 -awesomeversion==22.9.0 +awesomeversion==23.8.0 bcrypt==4.0.1 -bleak-retry-connector==3.1.1 -bleak==0.20.2 -bluetooth-adapters==0.16.0 -bluetooth-auto-recovery==1.2.1 -bluetooth-data-tools==1.9.1 +bleak-retry-connector==3.2.1 +bleak==0.21.1 +bluetooth-adapters==0.16.1 +bluetooth-auto-recovery==1.2.3 +bluetooth-data-tools==1.11.0 certifi>=2021.5.30 ciso8601==2.3.0 cryptography==41.0.3 -dbus-fast==1.94.1 +dbus-fast==2.9.0 fnv-hash-fast==0.4.1 ha-av==10.1.1 -hass-nabucasa==0.70.0 +hass-nabucasa==0.71.0 hassil==1.2.5 home-assistant-bluetooth==1.10.3 -home-assistant-frontend==20230901.0 +home-assistant-frontend==20230911.0 home-assistant-intents==2023.8.2 httpx==0.24.1 ifaddr==0.2.0 janus==1.0.0 Jinja2==3.1.2 lru-dict==1.2.0 -mutagen==1.46.0 -orjson==3.9.2 +mutagen==1.47.0 +orjson==3.9.7 packaging>=23.1 paho-mqtt==1.6.1 Pillow==10.0.0 @@ -46,14 +46,14 @@ pyudev==0.23.2 PyYAML==6.0.1 requests==2.31.0 scapy==2.5.0 -SQLAlchemy==2.0.15 -typing-extensions>=4.7.0,<5.0 +SQLAlchemy==2.0.21 +typing-extensions>=4.8.0,<5.0 ulid-transform==0.8.1 voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtcvad==2.0.10 yarl==1.9.2 -zeroconf==0.88.0 +zeroconf==0.112.0 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 @@ -71,9 +71,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.51.1 -grpcio-status==1.51.1 -grpcio-reflection==1.51.1 +grpcio==1.58.0 +grpcio-status==1.58.0 +grpcio-reflection==1.58.0 # libcst >=0.4.0 requires a newer Rust than we currently have available, # thus our wheels builds fail. This pins it to the last working version, @@ -112,7 +112,7 @@ httpcore==0.17.3 hyperframe>=5.2.0 # Ensure we run compatible with musllinux build env -numpy==1.23.2 +numpy==1.26.0 # Prevent dependency conflicts between sisyphus-control and aioambient # until upper bounds for sisyphus-control have been updated @@ -148,7 +148,7 @@ pyOpenSSL>=23.1.0 # protobuf must be in package constraints for the wheel # builder to build binary wheels -protobuf==4.24.0 +protobuf==4.24.3 # faust-cchardet: Ensure we have a version we can build wheels # 2.1.18 is the first version that works with our wheel builder diff --git a/homeassistant/runner.py b/homeassistant/runner.py index ed49db37f97147..10521f8013501a 100644 --- a/homeassistant/runner.py +++ b/homeassistant/runner.py @@ -163,8 +163,7 @@ async def setup_and_run_hass(runtime_config: RuntimeConfig) -> int: def _enable_posix_spawn() -> None: """Enable posix_spawn on Alpine Linux.""" - # pylint: disable=protected-access - if subprocess._USE_POSIX_SPAWN: + if subprocess._USE_POSIX_SPAWN: # pylint: disable=protected-access return # The subprocess module does not know about Alpine Linux/musl @@ -172,6 +171,7 @@ def _enable_posix_spawn() -> None: # less efficient. This is a workaround to force posix_spawn() # when using musl since cpython is not aware its supported. tag = next(packaging.tags.sys_tags()) + # pylint: disable-next=protected-access subprocess._USE_POSIX_SPAWN = "musllinux" in tag.platform diff --git a/homeassistant/scripts/check_config.py b/homeassistant/scripts/check_config.py index 38fa9cc2463f29..9a63c73590b5c2 100644 --- a/homeassistant/scripts/check_config.py +++ b/homeassistant/scripts/check_config.py @@ -30,7 +30,6 @@ REQUIREMENTS = ("colorlog==6.7.0",) _LOGGER = logging.getLogger(__name__) -# pylint: disable=protected-access MOCKS: dict[str, tuple[str, Callable]] = { "load": ("homeassistant.util.yaml.loader.load_yaml", yaml_loader.load_yaml), "load*": ("homeassistant.config.load_yaml", yaml_loader.load_yaml), @@ -166,13 +165,13 @@ def check(config_dir, secrets=False): "secret_cache": {}, } - # pylint: disable=possibly-unused-variable + # pylint: disable-next=possibly-unused-variable def mock_load(filename, secrets=None): """Mock hass.util.load_yaml to save config file names.""" res["yaml_files"][filename] = True return MOCKS["load"][1](filename, secrets) - # pylint: disable=possibly-unused-variable + # pylint: disable-next=possibly-unused-variable def mock_secrets(ldr, node): """Mock _get_secrets.""" try: @@ -201,7 +200,7 @@ def mock_secrets(ldr, node): def secrets_proxy(*args): secrets = Secrets(*args) - res["secret_cache"] = secrets._cache + res["secret_cache"] = secrets._cache # pylint: disable=protected-access return secrets try: diff --git a/homeassistant/util/language.py b/homeassistant/util/language.py index 4ec8c74ffa9fd7..73db81c91cebfd 100644 --- a/homeassistant/util/language.py +++ b/homeassistant/util/language.py @@ -199,3 +199,14 @@ def matches( # Score < 0 is not a match return [tag for _dialect, score, tag in scored if score[0] >= 0] + + +def intersect(languages_1: set[str], languages_2: set[str]) -> set[str]: + """Intersect two sets of languages using is_match for aliases.""" + languages = set() + for lang_1 in languages_1: + for lang_2 in languages_2: + if is_language_match(lang_1, lang_2): + languages.add(lang_1) + + return languages diff --git a/mypy.ini b/mypy.ini index 82cce328c6ae5d..67390ef2ddf32a 100644 --- a/mypy.ini +++ b/mypy.ini @@ -641,6 +641,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.climate.*] +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.cloud.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -1122,6 +1132,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.glances.*] +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.goalzero.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -1152,6 +1172,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.gpsd.*] +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.greeneye_monitor.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -1532,6 +1562,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.idasen_desk.*] +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.image.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -1622,6 +1662,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.islamic_prayer_times.*] +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.isy994.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -1892,6 +1942,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.matrix.*] +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.matter.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -2292,6 +2352,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.plugwise.*] +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.powerwall.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -2302,6 +2372,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.private_ble_device.*] +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.proximity.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -2873,6 +2953,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.switchbot_cloud.*] +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.switcher_kis.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -3083,6 +3173,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.trend.*] +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.tts.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/pylint/plugins/hass_enforce_super_call.py b/pylint/plugins/hass_enforce_super_call.py new file mode 100644 index 00000000000000..db4b2d4a5d7e59 --- /dev/null +++ b/pylint/plugins/hass_enforce_super_call.py @@ -0,0 +1,79 @@ +"""Plugin for checking super calls.""" +from __future__ import annotations + +from astroid import nodes +from pylint.checkers import BaseChecker +from pylint.interfaces import INFERENCE +from pylint.lint import PyLinter + +METHODS = { + "async_added_to_hass", +} + + +class HassEnforceSuperCallChecker(BaseChecker): # type: ignore[misc] + """Checker for super calls.""" + + name = "hass_enforce_super_call" + priority = -1 + msgs = { + "W7441": ( + "Missing call to: super().%s", + "hass-missing-super-call", + "Used when method should call its parent implementation.", + ), + } + options = () + + def visit_functiondef( + self, node: nodes.FunctionDef | nodes.AsyncFunctionDef + ) -> None: + """Check for super calls in method body.""" + if node.name not in METHODS: + return + + assert node.parent + parent = node.parent.frame() + if not isinstance(parent, nodes.ClassDef): + return + + # Check function body for super call + for child_node in node.body: + while isinstance(child_node, (nodes.Expr, nodes.Await, nodes.Return)): + child_node = child_node.value + match child_node: + case nodes.Call( + func=nodes.Attribute( + expr=nodes.Call(func=nodes.Name(name="super")), + attrname=node.name, + ), + ): + return + + # Check for non-empty base implementation + found_base_implementation = False + for base in parent.ancestors(): + for method in base.mymethods(): + if method.name != node.name: + continue + if method.body and not ( + len(method.body) == 1 and isinstance(method.body[0], nodes.Pass) + ): + found_base_implementation = True + break + + if found_base_implementation: + self.add_message( + "hass-missing-super-call", + node=node, + args=(node.name,), + confidence=INFERENCE, + ) + break + + visit_asyncfunctiondef = visit_functiondef + + +def register(linter: PyLinter) -> None: + """Register the checker.""" + linter.register_checker(HassEnforceSuperCallChecker(linter)) diff --git a/pyproject.toml b/pyproject.toml index 8f5b5c788fa035..7dfd584c59818f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ dependencies = [ "async-timeout==4.0.3", "attrs==23.1.0", "atomicwrites-homeassistant==1.4.1", - "awesomeversion==22.9.0", + "awesomeversion==23.8.0", "bcrypt==4.0.1", "certifi>=2021.5.30", "ciso8601==2.3.0", @@ -44,13 +44,13 @@ dependencies = [ "cryptography==41.0.3", # pyOpenSSL 23.2.0 is required to work with cryptography 41+ "pyOpenSSL==23.2.0", - "orjson==3.9.2", + "orjson==3.9.7", "packaging>=23.1", "pip>=21.3.1", "python-slugify==4.0.1", "PyYAML==6.0.1", "requests==2.31.0", - "typing-extensions>=4.7.0,<5.0", + "typing-extensions>=4.8.0,<5.0", "ulid-transform==0.8.1", "voluptuous==0.13.1", "voluptuous-serialize==2.6.0", @@ -100,6 +100,7 @@ init-hook = """\ load-plugins = [ "pylint.extensions.code_style", "pylint.extensions.typing", + "hass_enforce_super_call", "hass_enforce_type_hints", "hass_inheritance", "hass_imports", @@ -109,6 +110,7 @@ load-plugins = [ persistent = false extension-pkg-allow-list = [ "av.audio.stream", + "av.logging", "av.stream", "ciso8601", "orjson", @@ -288,6 +290,7 @@ disable = [ "use-list-literal", # C405 "useless-object-inheritance", # UP004 "useless-return", # PLR1711 + # "no-self-use", # PLR6301 # Optional plugin, not enabled # Handled by mypy # Ref: @@ -458,22 +461,34 @@ filterwarnings = [ "ignore:the imp module is deprecated in favour of importlib and slated for removal in Python 3.12:DeprecationWarning:future.standard_library", # https://github.com/foxel/python_ndms2_client/issues/6 - v0.1.2 "ignore:'telnetlib' is deprecated and slated for removal in Python 3.13:DeprecationWarning:ndms2_client.connection", - # https://github.com/grahamwetzler/smart-meter-texas/pull/143 - v0.5.3 - "ignore:ssl.OP_NO_SSL\\*/ssl.OP_NO_TLS\\* options are deprecated:DeprecationWarning:smart_meter_texas", # https://github.com/pytest-dev/pytest-cov/issues/557 - v4.1.0 # Should resolve itself once pytest-xdist 4.0 is released and the option is removed "ignore:The --rsyncdir command line argument and rsyncdirs config variable are deprecated:DeprecationWarning:xdist.plugin", # -- fixed, waiting for release / update - # https://github.com/gurumitts/pylutron-caseta/pull/143 - >0.18.1 - "ignore:ssl.PROTOCOL_TLSv1_2 is deprecated:DeprecationWarning:pylutron_caseta.smartbridge", - # https://github.com/Danielhiversen/pyMillLocal/pull/8 - >=0.3.0 - "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:mill_local", + # https://github.com/kurtmckee/feedparser/issues/330 - >6.0.10 + "ignore:'cgi' is deprecated and slated for removal in Python 3.13:DeprecationWarning:feedparser.encodings", + # https://github.com/jaraco/jaraco.abode/commit/9e3e789efc96cddcaa15f920686bbeb79a7469e0 - update jaraco.abode to >=5.1.0 + "ignore:`jaraco.functools.call_aside` is deprecated, use `jaraco.functools.invoke` instead:DeprecationWarning:jaraco.abode.helpers.timeline", + # https://github.com/poljar/matrix-nio/pull/438 - >0.21.2 + "ignore:FormatChecker.cls_checks is deprecated:DeprecationWarning:nio.schemas", + # https://github.com/poljar/matrix-nio/pull/439 - >0.21.2 + "ignore:'cgi' is deprecated and slated for removal in Python 3.13:DeprecationWarning:nio.client.http_client", + # https://github.com/grahamwetzler/smart-meter-texas/pull/143 - >0.5.3 + "ignore:ssl.OP_NO_SSL\\*/ssl.OP_NO_TLS\\* options are deprecated:DeprecationWarning:smart_meter_texas", # -- not helpful # pyatmo.__init__ imports deprecated moduls from itself - v7.5.0 "ignore:The module pyatmo.* is deprecated:DeprecationWarning:pyatmo", + # -- other + # Locale changes might take some time to resolve upstream + "ignore:Use setlocale\\(\\), getencoding\\(\\) and getlocale\\(\\) instead:DeprecationWarning:homematicip.base.base_connection", + "ignore:Use setlocale\\(\\), getencoding\\(\\) and getlocale\\(\\) instead:DeprecationWarning:micloud.micloud", + # Wrong stacklevel + # https://bugs.launchpad.net/beautifulsoup/+bug/2034451 + "ignore:It looks like you're parsing an XML document using an HTML parser:UserWarning:bs4.builder", + # -- unmaintained projects, last release about 2+ years # https://pypi.org/project/agent-py/ - v0.0.23 - 2020-06-04 "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:agent.a", @@ -488,6 +503,7 @@ filterwarnings = [ # https://pypi.org/project/lark-parser/ - v0.12.0 - 2021-08-30 -> moved to `lark` # https://pypi.org/project/commentjson/ - v0.9.0 - 2020-10-05 # https://github.com/vaidik/commentjson/issues/51 + # https://github.com/vaidik/commentjson/pull/52 # Fixed upstream, commentjson depends on old version and seems to be unmaintained "ignore:module '(sre_parse|sre_constants)' is deprecate:DeprecationWarning:lark.utils", # https://pypi.org/project/lomond/ - v0.3.3 - 2018-09-21 diff --git a/requirements.txt b/requirements.txt index e7a3b0fc4c5df3..40f7584ca3172e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,7 +6,7 @@ astral==2.2 async-timeout==4.0.3 attrs==23.1.0 atomicwrites-homeassistant==1.4.1 -awesomeversion==22.9.0 +awesomeversion==23.8.0 bcrypt==4.0.1 certifi>=2021.5.30 ciso8601==2.3.0 @@ -18,13 +18,13 @@ lru-dict==1.2.0 PyJWT==2.8.0 cryptography==41.0.3 pyOpenSSL==23.2.0 -orjson==3.9.2 +orjson==3.9.7 packaging>=23.1 pip>=21.3.1 python-slugify==4.0.1 PyYAML==6.0.1 requests==2.31.0 -typing-extensions>=4.7.0,<5.0 +typing-extensions>=4.8.0,<5.0 ulid-transform==0.8.1 voluptuous==0.13.1 voluptuous-serialize==2.6.0 diff --git a/requirements_all.txt b/requirements_all.txt index c84a21f3d8c478..545e05a7e8431f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -5,7 +5,7 @@ AEMET-OpenData==0.4.4 # homeassistant.components.aladdin_connect -AIOAladdinConnect==0.1.57 +AIOAladdinConnect==0.1.58 # homeassistant.components.honeywell AIOSomecomfort==0.0.17 @@ -29,7 +29,7 @@ DoorBirdPy==2.1.0 HAP-python==4.7.1 # homeassistant.components.tasmota -HATasmota==0.7.0 +HATasmota==0.7.3 # homeassistant.components.mastodon Mastodon.py==1.5.1 @@ -37,6 +37,7 @@ Mastodon.py==1.5.1 # homeassistant.components.doods # homeassistant.components.generic # homeassistant.components.image_upload +# homeassistant.components.matrix # homeassistant.components.proxy # homeassistant.components.qrcode # homeassistant.components.seven_segments @@ -48,7 +49,7 @@ Pillow==10.0.0 PlexAPI==4.13.2 # homeassistant.components.progettihwsw -ProgettiHWSW==0.1.1 +ProgettiHWSW==0.1.3 # homeassistant.components.bluetooth_tracker # PyBluez==0.22 @@ -121,14 +122,14 @@ PyXiaomiGateway==0.14.3 RachioPy==1.0.3 # homeassistant.components.python_script -RestrictedPython==6.1 +RestrictedPython==6.2 # homeassistant.components.remember_the_milk RtmAPI==0.7.2 # homeassistant.components.recorder # homeassistant.components.sql -SQLAlchemy==2.0.15 +SQLAlchemy==2.0.21 # homeassistant.components.travisci TravisPy==0.3.5 @@ -146,7 +147,7 @@ accuweather==1.0.0 adax==0.2.0 # homeassistant.components.androidtv -adb-shell[async]==0.4.3 +adb-shell[async]==0.4.4 # homeassistant.components.alarmdecoder adext==0.4.2 @@ -188,7 +189,7 @@ aioairq==0.2.4 aioairzone-cloud==0.2.1 # homeassistant.components.airzone -aioairzone==0.6.7 +aioairzone==0.6.8 # homeassistant.components.ambient_station aioambient==2023.04.0 @@ -212,7 +213,7 @@ aiobotocore==2.6.0 aiocomelit==0.0.5 # homeassistant.components.dhcp -aiodiscover==1.4.16 +aiodiscover==1.5.1 # homeassistant.components.dnsip # homeassistant.components.minecraft_server @@ -231,7 +232,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==16.0.3 +aioesphomeapi==16.0.5 # homeassistant.components.flo aioflo==2021.11.0 @@ -249,11 +250,11 @@ aioguardian==2022.07.0 aioharmony==0.2.10 # homeassistant.components.homekit_controller -aiohomekit==3.0.1 +aiohomekit==3.0.3 # homeassistant.components.emulated_hue # homeassistant.components.http -aiohttp-cors==0.7.0 +aiohttp_cors==0.7.0 # homeassistant.components.hue aiohue==4.6.2 @@ -327,13 +328,13 @@ aiopyarr==23.4.0 aioqsw==0.3.4 # homeassistant.components.recollect_waste -aiorecollect==1.0.8 +aiorecollect==2023.09.0 # homeassistant.components.ridwell aioridwell==2023.07.0 # homeassistant.components.ruckus_unleashed -aioruckus==0.31 +aioruckus==0.34 # homeassistant.components.ruuvi_gateway aioruuvigateway==0.1.0 @@ -360,16 +361,16 @@ aioswitcher==3.3.0 aiosyncthing==0.5.1 # homeassistant.components.tractive -aiotractive==0.5.5 +aiotractive==0.5.6 # homeassistant.components.unifi -aiounifi==58 +aiounifi==62 # homeassistant.components.vlc_telnet aiovlc==0.1.0 # homeassistant.components.vodafone_station -aiovodafone==0.0.6 +aiovodafone==0.2.0 # homeassistant.components.waqi aiowaqi==0.2.1 @@ -402,10 +403,10 @@ alpha-vantage==2.3.1 amberelectric==1.0.4 # homeassistant.components.amcrest -amcrest==1.9.7 +amcrest==1.9.8 # homeassistant.components.androidtv -androidtv[async]==0.0.70 +androidtv[async]==0.0.72 # homeassistant.components.androidtv_remote androidtvremote2==0.0.14 @@ -422,8 +423,11 @@ anthemav==1.4.1 # homeassistant.components.apcupsd apcaccess==0.0.13 +# homeassistant.components.weatherkit +apple_weatherkit==1.0.3 + # homeassistant.components.apprise -apprise==1.4.5 +apprise==1.5.0 # homeassistant.components.aprs aprslib==0.7.0 @@ -444,7 +448,10 @@ arris-tg2492lg==1.2.1 asmog==0.0.6 # homeassistant.components.asterisk_mbox -asterisk-mbox==0.5.0 +asterisk_mbox==0.5.0 + +# homeassistant.components.esphome +async-interrupt==1.1.1 # homeassistant.components.dlna_dmr # homeassistant.components.dlna_dms @@ -452,10 +459,7 @@ asterisk-mbox==0.5.0 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.35.0 - -# homeassistant.components.esphome -async_interrupt==1.1.1 +async-upnp-client==0.35.1 # homeassistant.components.keyboard_remote asyncinotify==4.0.2 @@ -509,7 +513,7 @@ beautifulsoup4==4.12.2 # beewi-smartclim==0.0.10 # homeassistant.components.zha -bellows==0.36.1 +bellows==0.36.3 # homeassistant.components.bmw_connected_drive bimmer-connected==0.14.0 @@ -518,10 +522,10 @@ bimmer-connected==0.14.0 bizkaibus==0.1.1 # homeassistant.components.bluetooth -bleak-retry-connector==3.1.1 +bleak-retry-connector==3.2.1 # homeassistant.components.bluetooth -bleak==0.20.2 +bleak==0.21.1 # homeassistant.components.blebox blebox-uniapi==2.1.4 @@ -540,16 +544,17 @@ bluemaestro-ble==0.2.3 # bluepy==1.3.0 # homeassistant.components.bluetooth -bluetooth-adapters==0.16.0 +bluetooth-adapters==0.16.1 # homeassistant.components.bluetooth -bluetooth-auto-recovery==1.2.1 +bluetooth-auto-recovery==1.2.3 # homeassistant.components.bluetooth # homeassistant.components.esphome # homeassistant.components.ld2410_ble # homeassistant.components.led_ble -bluetooth-data-tools==1.9.1 +# homeassistant.components.private_ble_device +bluetooth-data-tools==1.11.0 # homeassistant.components.bond bond-async==0.2.1 @@ -577,7 +582,7 @@ brunt==1.2.0 bt-proximity==0.2.1 # homeassistant.components.bthome -bthome-ble==3.1.0 +bthome-ble==3.1.1 # homeassistant.components.bt_home_hub_5 bthomehub5-devicelist==0.1.1 @@ -641,10 +646,10 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.bluetooth -dbus-fast==1.94.1 +dbus-fast==2.9.0 # homeassistant.components.debugpy -debugpy==1.6.7 +debugpy==1.8.0 # homeassistant.components.decora_wifi # decora-wifi==1.4 @@ -670,7 +675,7 @@ denonavr==0.11.3 devolo-home-control-api==0.18.2 # homeassistant.components.devolo_home_network -devolo-plc-api==1.4.0 +devolo-plc-api==1.4.1 # homeassistant.components.directv directv==0.4.0 @@ -724,7 +729,7 @@ elgato==4.0.1 eliqonline==1.2.2 # homeassistant.components.elkm1 -elkm1-lib==2.2.5 +elkm1-lib==2.2.6 # homeassistant.components.elmax elmax-api==0.0.4 @@ -751,7 +756,7 @@ env-canada==0.5.36 ephem==4.1.2 # homeassistant.components.epson -epson-projector==0.5.0 +epson-projector==0.5.1 # homeassistant.components.epsonworkforce epsonprinter==0.0.9 @@ -806,14 +811,14 @@ fjaraskupan==2.2.0 flipr-api==1.5.0 # homeassistant.components.flux_led -flux-led==1.0.2 +flux-led==1.0.4 # homeassistant.components.homekit # homeassistant.components.recorder fnv-hash-fast==0.4.1 # homeassistant.components.foobot -foobot-async==1.0.0 +foobot_async==1.0.0 # homeassistant.components.forecast_solar forecast-solar==3.0.0 @@ -829,13 +834,13 @@ freesms==0.2.0 # homeassistant.components.fritz # homeassistant.components.fritzbox_callmonitor -fritzconnection[qr]==1.12.2 +fritzconnection[qr]==1.13.2 # homeassistant.components.google_translate gTTS==2.2.4 # homeassistant.components.gardena_bluetooth -gardena_bluetooth==1.3.0 +gardena-bluetooth==1.4.0 # homeassistant.components.google_assistant_sdk gassist-text==0.0.10 @@ -863,7 +868,6 @@ georss-qld-bushfire-alert-client==0.5 # homeassistant.components.dlna_dmr # homeassistant.components.kef -# homeassistant.components.minecraft_server # homeassistant.components.nmap_tracker # homeassistant.components.samsungtv # homeassistant.components.upnp @@ -918,7 +922,7 @@ gps3==0.33.3 greeclimate==1.4.1 # homeassistant.components.greeneye_monitor -greeneye-monitor==3.0.3 +greeneye_monitor==3.0.3 # homeassistant.components.greenwave greenwavereality==0.5.1 @@ -958,7 +962,7 @@ ha-philipsjs==3.1.0 habitipy==0.2.0 # homeassistant.components.cloud -hass-nabucasa==0.70.0 +hass-nabucasa==0.71.0 # homeassistant.components.splunk hass-splunk==0.1.1 @@ -994,7 +998,7 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20230901.0 +home-assistant-frontend==20230911.0 # homeassistant.components.conversation home-assistant-intents==2023.8.2 @@ -1003,7 +1007,7 @@ home-assistant-intents==2023.8.2 homeconnect==0.7.2 # homeassistant.components.homematicip_cloud -homematicip==1.0.14 +homematicip==1.0.15 # homeassistant.components.home_plus_control homepluscontrol==0.0.5 @@ -1038,6 +1042,9 @@ ical==5.0.1 # homeassistant.components.ping icmplib==3.0 +# homeassistant.components.idasen_desk +idasen-ha==1.4 + # homeassistant.components.network ifaddr==0.2.0 @@ -1060,7 +1067,7 @@ influxdb==5.3.1 inkbird-ble==0.5.6 # homeassistant.components.insteon -insteon-frontend-home-assistant==0.3.5 +insteon-frontend-home-assistant==0.4.0 # homeassistant.components.intellifire intellifire4py==2.2.2 @@ -1069,7 +1076,7 @@ intellifire4py==2.2.2 iperf3==0.1.11 # homeassistant.components.gogogate2 -ismartgate==5.0.0 +ismartgate==5.0.1 # homeassistant.components.file_upload janus==1.0.0 @@ -1081,7 +1088,7 @@ jaraco.abode==3.3.0 jellyfin-apiclient-python==1.9.2 # homeassistant.components.rest -jsonpath==0.82 +jsonpath==0.82.2 # homeassistant.components.justnimbus justnimbus==0.6.0 @@ -1177,7 +1184,7 @@ lxml==4.9.3 mac-vendor-lookup==0.1.12 # homeassistant.components.matrix -matrix-client==0.4.0 +matrix-nio==0.21.2 # homeassistant.components.maxcube maxcube-api==0.4.3 @@ -1213,7 +1220,7 @@ micloud==0.5 mill-local==0.3.0 # homeassistant.components.mill -millheater==0.11.1 +millheater==0.11.5 # homeassistant.components.minio minio==7.1.12 @@ -1237,7 +1244,7 @@ motioneye-client==0.3.14 mullvad-api==1.0.0 # homeassistant.components.tts -mutagen==1.46.0 +mutagen==1.47.0 # homeassistant.components.mutesync mutesync==0.0.1 @@ -1311,7 +1318,7 @@ numato-gpio==0.10.0 # homeassistant.components.stream # homeassistant.components.tensorflow # homeassistant.components.trend -numpy==1.23.2 +numpy==1.26.0 # homeassistant.components.oasa_telematics oasatelematics==0.3 @@ -1374,7 +1381,7 @@ openwrt-luci-rpc==1.1.16 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.0.33 +opower==0.0.34 # homeassistant.components.oralb oralb-ble==0.17.6 @@ -1438,7 +1445,7 @@ plexauth==0.0.6 plexwebsocket==0.0.13 # homeassistant.components.plugwise -plugwise==0.31.9 +plugwise==0.32.2 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 @@ -1481,7 +1488,7 @@ pure-python-adb[async]==0.3.0.dev0 pushbullet.py==0.11.0 # homeassistant.components.pushover -pushover-complete==1.1.1 +pushover_complete==1.1.1 # homeassistant.components.pvoutput pvo==1.0.0 @@ -1528,6 +1535,9 @@ pyCEC==0.5.2 # homeassistant.components.control4 pyControl4==1.1.0 +# homeassistant.components.duotecno +pyDuotecno==2023.8.4 + # homeassistant.components.eight_sleep pyEight==0.3.2 @@ -1547,7 +1557,7 @@ pyRFXtrx==0.30.1 pySDCP==1 # homeassistant.components.tibber -pyTibber==0.28.0 +pyTibber==0.28.2 # homeassistant.components.dlink pyW215==0.7.0 @@ -1655,12 +1665,12 @@ pydrawise==2023.8.0 # homeassistant.components.android_ip_webcam pydroid-ipcam==2.0.0 -# homeassistant.components.duotecno -pyduotecno==2023.8.4 - # homeassistant.components.ebox pyebox==1.1.4 +# homeassistant.components.ecoforest +pyecoforest==0.3.0 + # homeassistant.components.econet pyeconet==0.1.20 @@ -1671,7 +1681,7 @@ pyedimax==0.2.1 pyefergy==22.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.8.1 +pyenphase==1.11.4 # homeassistant.components.envisalink pyenvisalink==4.6 @@ -1746,7 +1756,7 @@ pyialarm==2.2.0 pyicloud==1.0.0 # homeassistant.components.insteon -pyinsteon==1.4.3 +pyinsteon==1.5.1 # homeassistant.components.intesishome pyintesishome==1.8.0 @@ -1821,7 +1831,7 @@ pylitejet==0.5.0 pylitterbot==2023.4.5 # homeassistant.components.lutron_caseta -pylutron-caseta==0.18.1 +pylutron-caseta==0.18.2 # homeassistant.components.lutron pylutron==0.2.8 @@ -1851,7 +1861,7 @@ pymitv==1.4.3 pymochad==0.2.0 # homeassistant.components.modbus -pymodbus==3.5.0 +pymodbus==3.5.2 # homeassistant.components.monoprice pymonoprice==0.4 @@ -1866,7 +1876,7 @@ pymyq==3.1.4 pymysensors==0.24.0 # homeassistant.components.netgear -pynetgear==0.10.9 +pynetgear==0.10.10 # homeassistant.components.netio pynetio==0.1.9.1 @@ -1916,7 +1926,7 @@ pyotgw==2.1.3 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.10.1 +pyoverkiz==1.9.0 # homeassistant.components.openweathermap pyowm==3.2.0 @@ -1988,7 +1998,7 @@ pysabnzbd==1.1.1 pysaj==0.0.16 # homeassistant.components.schlage -pyschlage==2023.8.1 +pyschlage==2023.9.0 # homeassistant.components.sensibo pysensibo==1.0.33 @@ -2107,7 +2117,7 @@ python-gc100==1.0.3a0 python-gitlab==1.6.0 # homeassistant.components.homewizard -python-homewizard-energy==2.0.2 +python-homewizard-energy==2.1.0 # homeassistant.components.hp_ilo python-hpilo==4.3 @@ -2159,7 +2169,7 @@ python-qbittorrent==0.4.3 python-ripple-api==0.0.3 # homeassistant.components.roborock -python-roborock==0.32.3 +python-roborock==0.34.1 # homeassistant.components.smarttub python-smarttub==0.0.33 @@ -2198,13 +2208,13 @@ pytradfri[async]==9.0.1 # homeassistant.components.trafikverket_ferry # homeassistant.components.trafikverket_train # homeassistant.components.trafikverket_weatherstation -pytrafikverket==0.3.5 +pytrafikverket==0.3.6 # homeassistant.components.usb pyudev==0.23.2 # homeassistant.components.unifiprotect -pyunifiprotect==4.10.6 +pyunifiprotect==4.20.0 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 @@ -2231,7 +2241,7 @@ pyvlx==0.2.20 pyvolumio==0.1.5 # homeassistant.components.waze_travel_time -pywaze==0.3.0 +pywaze==0.5.0 # homeassistant.components.html5 pywebpush==1.9.2 @@ -2291,10 +2301,10 @@ regenmaschine==2023.06.0 renault-api==0.2.0 # homeassistant.components.renson -renson-endura-delta==1.5.0 +renson-endura-delta==1.6.0 # homeassistant.components.reolink -reolink-aio==0.7.8 +reolink-aio==0.7.10 # homeassistant.components.idteck_prox rfk101py==0.0.1 @@ -2357,7 +2367,7 @@ satel-integra==0.3.7 scapy==2.5.0 # homeassistant.components.screenlogic -screenlogicpy==0.8.2 +screenlogicpy==0.9.0 # homeassistant.components.scsgate scsgate==0.1.0 @@ -2368,14 +2378,12 @@ securetar==2023.3.0 # homeassistant.components.sendgrid sendgrid==6.8.2 -# homeassistant.components.sense -sense-energy==0.12.0 - # homeassistant.components.emulated_kasa -sense_energy==0.12.0 +# homeassistant.components.sense +sense-energy==0.12.2 # homeassistant.components.sensirion_ble -sensirion-ble==0.1.0 +sensirion-ble==0.1.1 # homeassistant.components.sensorpro sensorpro-ble==0.5.3 @@ -2384,7 +2392,7 @@ sensorpro-ble==0.5.3 sensorpush-ble==1.5.5 # homeassistant.components.sentry -sentry-sdk==1.28.1 +sentry-sdk==1.31.0 # homeassistant.components.sfr_box sfrbox-api==0.0.6 @@ -2393,7 +2401,7 @@ sfrbox-api==0.0.6 sharkiq==1.0.2 # homeassistant.components.aquostv -sharp-aquos-rc==0.3.2 +sharp_aquos_rc==0.3.2 # homeassistant.components.shodan shodan==1.28.0 @@ -2402,7 +2410,7 @@ shodan==1.28.0 simplehound==0.3 # homeassistant.components.simplepush -simplepush==2.1.1 +simplepush==2.2.3 # homeassistant.components.simplisafe simplisafe-python==2023.08.0 @@ -2500,11 +2508,14 @@ surepy==0.8.0 # homeassistant.components.swiss_hydrological_data swisshydrodata==0.1.0 +# homeassistant.components.switchbot_cloud +switchbot-api==1.1.0 + # homeassistant.components.synology_srm synology-srm==0.2.0 # homeassistant.components.system_bridge -systembridgeconnector==3.4.9 +systembridgeconnector==3.8.2 # homeassistant.components.tailscale tailscale==0.2.0 @@ -2579,7 +2590,7 @@ total-connect-client==2023.2 tp-connected==0.0.4 # homeassistant.components.tplink_omada -tplink_omada_client==1.3.2 +tplink-omada-client==1.3.2 # homeassistant.components.transmission transmission-rpc==4.1.5 @@ -2603,7 +2614,7 @@ twitchAPI==3.10.0 uasiren==0.0.1 # homeassistant.components.landisgyr_heat_meter -ultraheat-api==0.5.1 +ultraheat-api==0.5.7 # homeassistant.components.unifiprotect unifi-discovery==1.1.7 @@ -2722,7 +2733,6 @@ xknxproject==3.2.0 # homeassistant.components.bluesound # homeassistant.components.fritz # homeassistant.components.rest -# homeassistant.components.ruckus_unleashed # homeassistant.components.startca # homeassistant.components.ted5000 # homeassistant.components.zestimate @@ -2736,10 +2746,10 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.2.3 +yalexs-ble==2.3.0 # homeassistant.components.august -yalexs==1.8.0 +yalexs==1.9.0 # homeassistant.components.yeelight yeelight==0.7.13 @@ -2748,7 +2758,7 @@ yeelight==0.7.13 yeelightsunflower==0.0.10 # homeassistant.components.yolink -yolink-api==0.3.0 +yolink-api==0.3.1 # homeassistant.components.youless youless-api==1.0.1 @@ -2760,13 +2770,13 @@ youtubeaio==1.1.5 yt-dlp==2023.7.6 # homeassistant.components.zamg -zamg==0.2.4 +zamg==0.3.0 # homeassistant.components.zengge zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.88.0 +zeroconf==0.112.0 # homeassistant.components.zeversolar zeversolar==0.3.1 @@ -2781,10 +2791,10 @@ zhong-hong-hvac==1.0.9 ziggo-mediabox-xl==1.1.0 # homeassistant.components.zha -zigpy-deconz==0.21.0 +zigpy-deconz==0.21.1 # homeassistant.components.zha -zigpy-xbee==0.18.1 +zigpy-xbee==0.18.2 # homeassistant.components.zha zigpy-zigate==0.11.0 @@ -2793,13 +2803,13 @@ zigpy-zigate==0.11.0 zigpy-znp==0.11.4 # homeassistant.components.zha -zigpy==0.57.0 +zigpy==0.57.1 # homeassistant.components.zoneminder zm-py==0.5.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.51.0 +zwave-js-server-python==0.51.3 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/requirements_test.txt b/requirements_test.txt index a2533d0ef2b629..2d0c256ac26283 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -8,17 +8,17 @@ -c homeassistant/package_constraints.txt -r requirements_test_pre_commit.txt astroid==2.15.4 -coverage==7.3.0 +coverage==7.3.1 freezegun==1.2.2 mock-open==1.4.0 mypy==1.5.1 -pre-commit==3.3.3 +pre-commit==3.4.0 pydantic==1.10.12 pylint==2.17.4 pylint-per-file-ignores==1.2.1 pipdeptree==2.11.0 pytest-asyncio==0.21.0 -pytest-aiohttp==1.0.4 +pytest-aiohttp==1.0.5 pytest-cov==4.1.0 pytest-freezer==0.4.8 pytest-socket==0.6.0 @@ -29,10 +29,11 @@ pytest-unordered==0.5.2 pytest-picked==0.4.6 pytest-xdist==3.3.1 pytest==7.3.1 -requests_mock==1.11.0 +requests-mock==1.11.0 respx==0.20.2 -syrupy==4.2.1 +syrupy==4.5.0 tqdm==4.66.1 +types-aiofiles==22.1.0 types-atomicwrites==1.4.5.1 types-croniter==1.0.6 types-backports==0.1.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ef67aca2937915..e04213f0c8e528 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -7,7 +7,7 @@ AEMET-OpenData==0.4.4 # homeassistant.components.aladdin_connect -AIOAladdinConnect==0.1.57 +AIOAladdinConnect==0.1.58 # homeassistant.components.honeywell AIOSomecomfort==0.0.17 @@ -28,11 +28,12 @@ DoorBirdPy==2.1.0 HAP-python==4.7.1 # homeassistant.components.tasmota -HATasmota==0.7.0 +HATasmota==0.7.3 # homeassistant.components.doods # homeassistant.components.generic # homeassistant.components.image_upload +# homeassistant.components.matrix # homeassistant.components.proxy # homeassistant.components.qrcode # homeassistant.components.seven_segments @@ -44,7 +45,7 @@ Pillow==10.0.0 PlexAPI==4.13.2 # homeassistant.components.progettihwsw -ProgettiHWSW==0.1.1 +ProgettiHWSW==0.1.3 # homeassistant.components.cast PyChromecast==13.0.7 @@ -108,14 +109,14 @@ PyXiaomiGateway==0.14.3 RachioPy==1.0.3 # homeassistant.components.python_script -RestrictedPython==6.1 +RestrictedPython==6.2 # homeassistant.components.remember_the_milk RtmAPI==0.7.2 # homeassistant.components.recorder # homeassistant.components.sql -SQLAlchemy==2.0.15 +SQLAlchemy==2.0.21 # homeassistant.components.onvif WSDiscovery==2.0.0 @@ -127,7 +128,7 @@ accuweather==1.0.0 adax==0.2.0 # homeassistant.components.androidtv -adb-shell[async]==0.4.3 +adb-shell[async]==0.4.4 # homeassistant.components.alarmdecoder adext==0.4.2 @@ -169,7 +170,7 @@ aioairq==0.2.4 aioairzone-cloud==0.2.1 # homeassistant.components.airzone -aioairzone==0.6.7 +aioairzone==0.6.8 # homeassistant.components.ambient_station aioambient==2023.04.0 @@ -193,7 +194,7 @@ aiobotocore==2.6.0 aiocomelit==0.0.5 # homeassistant.components.dhcp -aiodiscover==1.4.16 +aiodiscover==1.5.1 # homeassistant.components.dnsip # homeassistant.components.minecraft_server @@ -212,7 +213,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==16.0.3 +aioesphomeapi==16.0.5 # homeassistant.components.flo aioflo==2021.11.0 @@ -227,11 +228,11 @@ aioguardian==2022.07.0 aioharmony==0.2.10 # homeassistant.components.homekit_controller -aiohomekit==3.0.1 +aiohomekit==3.0.3 # homeassistant.components.emulated_hue # homeassistant.components.http -aiohttp-cors==0.7.0 +aiohttp_cors==0.7.0 # homeassistant.components.hue aiohue==4.6.2 @@ -302,13 +303,13 @@ aiopyarr==23.4.0 aioqsw==0.3.4 # homeassistant.components.recollect_waste -aiorecollect==1.0.8 +aiorecollect==2023.09.0 # homeassistant.components.ridwell aioridwell==2023.07.0 # homeassistant.components.ruckus_unleashed -aioruckus==0.31 +aioruckus==0.34 # homeassistant.components.ruuvi_gateway aioruuvigateway==0.1.0 @@ -335,16 +336,19 @@ aioswitcher==3.3.0 aiosyncthing==0.5.1 # homeassistant.components.tractive -aiotractive==0.5.5 +aiotractive==0.5.6 # homeassistant.components.unifi -aiounifi==58 +aiounifi==62 # homeassistant.components.vlc_telnet aiovlc==0.1.0 # homeassistant.components.vodafone_station -aiovodafone==0.0.6 +aiovodafone==0.2.0 + +# homeassistant.components.waqi +aiowaqi==0.2.1 # homeassistant.components.watttime aiowatttime==0.1.1 @@ -371,7 +375,7 @@ airtouch4pyapi==1.0.5 amberelectric==1.0.4 # homeassistant.components.androidtv -androidtv[async]==0.0.70 +androidtv[async]==0.0.72 # homeassistant.components.androidtv_remote androidtvremote2==0.0.14 @@ -385,8 +389,11 @@ anthemav==1.4.1 # homeassistant.components.apcupsd apcaccess==0.0.13 +# homeassistant.components.weatherkit +apple_weatherkit==1.0.3 + # homeassistant.components.apprise -apprise==1.4.5 +apprise==1.5.0 # homeassistant.components.aprs aprslib==0.7.0 @@ -397,16 +404,16 @@ aranet4==2.1.3 # homeassistant.components.arcam_fmj arcam-fmj==1.4.0 +# homeassistant.components.esphome +async-interrupt==1.1.1 + # homeassistant.components.dlna_dmr # homeassistant.components.dlna_dms # homeassistant.components.samsungtv # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.35.0 - -# homeassistant.components.esphome -async_interrupt==1.1.1 +async-upnp-client==0.35.1 # homeassistant.components.sleepiq asyncsleepiq==1.3.7 @@ -430,16 +437,16 @@ base36==0.1.1 beautifulsoup4==4.12.2 # homeassistant.components.zha -bellows==0.36.1 +bellows==0.36.3 # homeassistant.components.bmw_connected_drive bimmer-connected==0.14.0 # homeassistant.components.bluetooth -bleak-retry-connector==3.1.1 +bleak-retry-connector==3.2.1 # homeassistant.components.bluetooth -bleak==0.20.2 +bleak==0.21.1 # homeassistant.components.blebox blebox-uniapi==2.1.4 @@ -451,16 +458,17 @@ blinkpy==0.21.0 bluemaestro-ble==0.2.3 # homeassistant.components.bluetooth -bluetooth-adapters==0.16.0 +bluetooth-adapters==0.16.1 # homeassistant.components.bluetooth -bluetooth-auto-recovery==1.2.1 +bluetooth-auto-recovery==1.2.3 # homeassistant.components.bluetooth # homeassistant.components.esphome # homeassistant.components.ld2410_ble # homeassistant.components.led_ble -bluetooth-data-tools==1.9.1 +# homeassistant.components.private_ble_device +bluetooth-data-tools==1.11.0 # homeassistant.components.bond bond-async==0.2.1 @@ -481,7 +489,7 @@ brottsplatskartan==0.0.1 brunt==1.2.0 # homeassistant.components.bthome -bthome-ble==3.1.0 +bthome-ble==3.1.1 # homeassistant.components.buienradar buienradar==1.0.5 @@ -521,10 +529,10 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.bluetooth -dbus-fast==1.94.1 +dbus-fast==2.9.0 # homeassistant.components.debugpy -debugpy==1.6.7 +debugpy==1.8.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns @@ -544,7 +552,7 @@ denonavr==0.11.3 devolo-home-control-api==0.18.2 # homeassistant.components.devolo_home_network -devolo-plc-api==1.4.0 +devolo-plc-api==1.4.1 # homeassistant.components.directv directv==0.4.0 @@ -580,7 +588,7 @@ electrickiwi-api==0.8.5 elgato==4.0.1 # homeassistant.components.elkm1 -elkm1-lib==2.2.5 +elkm1-lib==2.2.6 # homeassistant.components.elmax elmax-api==0.0.4 @@ -604,7 +612,7 @@ env-canada==0.5.36 ephem==4.1.2 # homeassistant.components.epson -epson-projector==0.5.0 +epson-projector==0.5.1 # homeassistant.components.esphome esphome-dashboard-api==1.2.3 @@ -631,14 +639,14 @@ fjaraskupan==2.2.0 flipr-api==1.5.0 # homeassistant.components.flux_led -flux-led==1.0.2 +flux-led==1.0.4 # homeassistant.components.homekit # homeassistant.components.recorder fnv-hash-fast==0.4.1 # homeassistant.components.foobot -foobot-async==1.0.0 +foobot_async==1.0.0 # homeassistant.components.forecast_solar forecast-solar==3.0.0 @@ -648,13 +656,13 @@ freebox-api==1.1.0 # homeassistant.components.fritz # homeassistant.components.fritzbox_callmonitor -fritzconnection[qr]==1.12.2 +fritzconnection[qr]==1.13.2 # homeassistant.components.google_translate gTTS==2.2.4 # homeassistant.components.gardena_bluetooth -gardena_bluetooth==1.3.0 +gardena-bluetooth==1.4.0 # homeassistant.components.google_assistant_sdk gassist-text==0.0.10 @@ -679,7 +687,6 @@ georss-qld-bushfire-alert-client==0.5 # homeassistant.components.dlna_dmr # homeassistant.components.kef -# homeassistant.components.minecraft_server # homeassistant.components.nmap_tracker # homeassistant.components.samsungtv # homeassistant.components.upnp @@ -719,7 +726,7 @@ govee-ble==0.23.0 greeclimate==1.4.1 # homeassistant.components.greeneye_monitor -greeneye-monitor==3.0.3 +greeneye_monitor==3.0.3 # homeassistant.components.pure_energie gridnet==4.2.0 @@ -753,7 +760,7 @@ ha-philipsjs==3.1.0 habitipy==0.2.0 # homeassistant.components.cloud -hass-nabucasa==0.70.0 +hass-nabucasa==0.71.0 # homeassistant.components.conversation hassil==1.2.5 @@ -777,7 +784,7 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20230901.0 +home-assistant-frontend==20230911.0 # homeassistant.components.conversation home-assistant-intents==2023.8.2 @@ -786,7 +793,7 @@ home-assistant-intents==2023.8.2 homeconnect==0.7.2 # homeassistant.components.homematicip_cloud -homematicip==1.0.14 +homematicip==1.0.15 # homeassistant.components.home_plus_control homepluscontrol==0.0.5 @@ -812,6 +819,9 @@ ical==5.0.1 # homeassistant.components.ping icmplib==3.0 +# homeassistant.components.idasen_desk +idasen-ha==1.4 + # homeassistant.components.network ifaddr==0.2.0 @@ -825,13 +835,13 @@ influxdb==5.3.1 inkbird-ble==0.5.6 # homeassistant.components.insteon -insteon-frontend-home-assistant==0.3.5 +insteon-frontend-home-assistant==0.4.0 # homeassistant.components.intellifire intellifire4py==2.2.2 # homeassistant.components.gogogate2 -ismartgate==5.0.0 +ismartgate==5.0.1 # homeassistant.components.file_upload janus==1.0.0 @@ -843,7 +853,7 @@ jaraco.abode==3.3.0 jellyfin-apiclient-python==1.9.2 # homeassistant.components.rest -jsonpath==0.82 +jsonpath==0.82.2 # homeassistant.components.justnimbus justnimbus==0.6.0 @@ -887,6 +897,9 @@ life360==6.0.0 # homeassistant.components.logi_circle logi-circle==0.2.3 +# homeassistant.components.london_underground +london-tube-status==0.5 + # homeassistant.components.loqed loqedAPI==2.1.7 @@ -899,6 +912,9 @@ lxml==4.9.3 # homeassistant.components.nmap_tracker mac-vendor-lookup==0.1.12 +# homeassistant.components.matrix +matrix-nio==0.21.2 + # homeassistant.components.maxcube maxcube-api==0.4.3 @@ -927,7 +943,7 @@ micloud==0.5 mill-local==0.3.0 # homeassistant.components.mill -millheater==0.11.1 +millheater==0.11.5 # homeassistant.components.minio minio==7.1.12 @@ -951,7 +967,7 @@ motioneye-client==0.3.14 mullvad-api==1.0.0 # homeassistant.components.tts -mutagen==1.46.0 +mutagen==1.47.0 # homeassistant.components.mutesync mutesync==0.0.1 @@ -1004,7 +1020,7 @@ numato-gpio==0.10.0 # homeassistant.components.stream # homeassistant.components.tensorflow # homeassistant.components.trend -numpy==1.23.2 +numpy==1.26.0 # homeassistant.components.google oauth2client==4.1.3 @@ -1040,7 +1056,7 @@ openerz-api==0.2.0 openhomedevice==2.2.0 # homeassistant.components.opower -opower==0.0.33 +opower==0.0.34 # homeassistant.components.oralb oralb-ble==0.17.6 @@ -1086,7 +1102,7 @@ plexauth==0.0.6 plexwebsocket==0.0.13 # homeassistant.components.plugwise -plugwise==0.31.9 +plugwise==0.32.2 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 @@ -1114,7 +1130,7 @@ pure-python-adb[async]==0.3.0.dev0 pushbullet.py==0.11.0 # homeassistant.components.pushover -pushover-complete==1.1.1 +pushover_complete==1.1.1 # homeassistant.components.pvoutput pvo==1.0.0 @@ -1149,6 +1165,9 @@ pyCEC==0.5.2 # homeassistant.components.control4 pyControl4==1.1.0 +# homeassistant.components.duotecno +pyDuotecno==2023.8.4 + # homeassistant.components.eight_sleep pyEight==0.3.2 @@ -1159,7 +1178,7 @@ pyElectra==1.2.0 pyRFXtrx==0.30.1 # homeassistant.components.tibber -pyTibber==0.28.0 +pyTibber==0.28.2 # homeassistant.components.dlink pyW215==0.7.0 @@ -1225,8 +1244,8 @@ pydiscovergy==2.0.3 # homeassistant.components.android_ip_webcam pydroid-ipcam==2.0.0 -# homeassistant.components.duotecno -pyduotecno==2023.8.4 +# homeassistant.components.ecoforest +pyecoforest==0.3.0 # homeassistant.components.econet pyeconet==0.1.20 @@ -1235,7 +1254,7 @@ pyeconet==0.1.20 pyefergy==22.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.8.1 +pyenphase==1.11.4 # homeassistant.components.everlights pyeverlights==0.1.0 @@ -1292,7 +1311,7 @@ pyialarm==2.2.0 pyicloud==1.0.0 # homeassistant.components.insteon -pyinsteon==1.4.3 +pyinsteon==1.5.1 # homeassistant.components.ipma pyipma==3.0.6 @@ -1349,7 +1368,7 @@ pylitejet==0.5.0 pylitterbot==2023.4.5 # homeassistant.components.lutron_caseta -pylutron-caseta==0.18.1 +pylutron-caseta==0.18.2 # homeassistant.components.mailgun pymailgunner==1.4 @@ -1370,7 +1389,7 @@ pymeteoclimatic==0.0.6 pymochad==0.2.0 # homeassistant.components.modbus -pymodbus==3.5.0 +pymodbus==3.5.2 # homeassistant.components.monoprice pymonoprice==0.4 @@ -1382,7 +1401,7 @@ pymyq==3.1.4 pymysensors==0.24.0 # homeassistant.components.netgear -pynetgear==0.10.9 +pynetgear==0.10.10 # homeassistant.components.nobo_hub pynobo==1.6.0 @@ -1423,7 +1442,7 @@ pyotgw==2.1.3 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.10.1 +pyoverkiz==1.9.0 # homeassistant.components.openweathermap pyowm==3.2.0 @@ -1477,7 +1496,7 @@ pyrympro==0.0.7 pysabnzbd==1.1.1 # homeassistant.components.schlage -pyschlage==2023.8.1 +pyschlage==2023.9.0 # homeassistant.components.sensibo pysensibo==1.0.33 @@ -1551,7 +1570,7 @@ python-ecobee-api==0.2.14 python-fullykiosk==0.0.12 # homeassistant.components.homewizard -python-homewizard-energy==2.0.2 +python-homewizard-energy==2.1.0 # homeassistant.components.izone python-izone==1.2.9 @@ -1585,7 +1604,7 @@ python-picnic-api==1.1.0 python-qbittorrent==0.4.3 # homeassistant.components.roborock -python-roborock==0.32.3 +python-roborock==0.34.1 # homeassistant.components.smarttub python-smarttub==0.0.33 @@ -1615,13 +1634,13 @@ pytradfri[async]==9.0.1 # homeassistant.components.trafikverket_ferry # homeassistant.components.trafikverket_train # homeassistant.components.trafikverket_weatherstation -pytrafikverket==0.3.5 +pytrafikverket==0.3.6 # homeassistant.components.usb pyudev==0.23.2 # homeassistant.components.unifiprotect -pyunifiprotect==4.10.6 +pyunifiprotect==4.20.0 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 @@ -1639,7 +1658,7 @@ pyvizio==0.1.61 pyvolumio==0.1.5 # homeassistant.components.waze_travel_time -pywaze==0.3.0 +pywaze==0.5.0 # homeassistant.components.html5 pywebpush==1.9.2 @@ -1656,6 +1675,9 @@ pywizlight==0.5.14 # homeassistant.components.ws66i pyws66i==1.1 +# homeassistant.components.yardian +pyyardian==1.1.0 + # homeassistant.components.zerproc pyzerproc==0.4.8 @@ -1681,10 +1703,10 @@ regenmaschine==2023.06.0 renault-api==0.2.0 # homeassistant.components.renson -renson-endura-delta==1.5.0 +renson-endura-delta==1.6.0 # homeassistant.components.reolink -reolink-aio==0.7.8 +reolink-aio==0.7.10 # homeassistant.components.rflink rflink==0.0.65 @@ -1723,19 +1745,17 @@ samsungtvws[async,encrypted]==2.6.0 scapy==2.5.0 # homeassistant.components.screenlogic -screenlogicpy==0.8.2 +screenlogicpy==0.9.0 # homeassistant.components.backup securetar==2023.3.0 -# homeassistant.components.sense -sense-energy==0.12.0 - # homeassistant.components.emulated_kasa -sense_energy==0.12.0 +# homeassistant.components.sense +sense-energy==0.12.2 # homeassistant.components.sensirion_ble -sensirion-ble==0.1.0 +sensirion-ble==0.1.1 # homeassistant.components.sensorpro sensorpro-ble==0.5.3 @@ -1744,7 +1764,7 @@ sensorpro-ble==0.5.3 sensorpush-ble==1.5.5 # homeassistant.components.sentry -sentry-sdk==1.28.1 +sentry-sdk==1.31.0 # homeassistant.components.sfr_box sfrbox-api==0.0.6 @@ -1756,7 +1776,7 @@ sharkiq==1.0.2 simplehound==0.3 # homeassistant.components.simplepush -simplepush==2.1.1 +simplepush==2.2.3 # homeassistant.components.simplisafe simplisafe-python==2023.08.0 @@ -1836,8 +1856,11 @@ sunwatcher==0.2.1 # homeassistant.components.surepetcare surepy==0.8.0 +# homeassistant.components.switchbot_cloud +switchbot-api==1.1.0 + # homeassistant.components.system_bridge -systembridgeconnector==3.4.9 +systembridgeconnector==3.8.2 # homeassistant.components.tailscale tailscale==0.2.0 @@ -1879,7 +1902,7 @@ toonapi==0.2.1 total-connect-client==2023.2 # homeassistant.components.tplink_omada -tplink_omada_client==1.3.2 +tplink-omada-client==1.3.2 # homeassistant.components.transmission transmission-rpc==4.1.5 @@ -1903,7 +1926,7 @@ twitchAPI==3.10.0 uasiren==0.0.1 # homeassistant.components.landisgyr_heat_meter -ultraheat-api==0.5.1 +ultraheat-api==0.5.7 # homeassistant.components.unifiprotect unifi-discovery==1.1.7 @@ -2004,7 +2027,6 @@ xknxproject==3.2.0 # homeassistant.components.bluesound # homeassistant.components.fritz # homeassistant.components.rest -# homeassistant.components.ruckus_unleashed # homeassistant.components.startca # homeassistant.components.ted5000 # homeassistant.components.zestimate @@ -2015,16 +2037,16 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.2.3 +yalexs-ble==2.3.0 # homeassistant.components.august -yalexs==1.8.0 +yalexs==1.9.0 # homeassistant.components.yeelight yeelight==0.7.13 # homeassistant.components.yolink -yolink-api==0.3.0 +yolink-api==0.3.1 # homeassistant.components.youless youless-api==1.0.1 @@ -2033,10 +2055,10 @@ youless-api==1.0.1 youtubeaio==1.1.5 # homeassistant.components.zamg -zamg==0.2.4 +zamg==0.3.0 # homeassistant.components.zeroconf -zeroconf==0.88.0 +zeroconf==0.112.0 # homeassistant.components.zeversolar zeversolar==0.3.1 @@ -2045,10 +2067,10 @@ zeversolar==0.3.1 zha-quirks==0.0.103 # homeassistant.components.zha -zigpy-deconz==0.21.0 +zigpy-deconz==0.21.1 # homeassistant.components.zha -zigpy-xbee==0.18.1 +zigpy-xbee==0.18.2 # homeassistant.components.zha zigpy-zigate==0.11.0 @@ -2057,10 +2079,10 @@ zigpy-zigate==0.11.0 zigpy-znp==0.11.4 # homeassistant.components.zha -zigpy==0.57.0 +zigpy==0.57.1 # homeassistant.components.zwave_js -zwave-js-server-python==0.51.0 +zwave-js-server-python==0.51.3 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index 844d796e7af72a..dadc3e0cab270d 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -1,6 +1,6 @@ # Automatically generated from .pre-commit-config.yaml by gen_requirements_all.py, do not edit -black==23.7.0 +black==23.9.1 codespell==2.2.2 -ruff==0.0.285 +ruff==0.0.289 yamllint==1.32.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 101a57e419d780..e0e00ebc958618 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -4,6 +4,7 @@ import difflib import importlib +from operator import itemgetter import os from pathlib import Path import pkgutil @@ -71,9 +72,9 @@ # 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.51.1 -grpcio-status==1.51.1 -grpcio-reflection==1.51.1 +grpcio==1.58.0 +grpcio-status==1.58.0 +grpcio-reflection==1.58.0 # libcst >=0.4.0 requires a newer Rust than we currently have available, # thus our wheels builds fail. This pins it to the last working version, @@ -112,7 +113,7 @@ hyperframe>=5.2.0 # Ensure we run compatible with musllinux build env -numpy==1.23.2 +numpy==1.26.0 # Prevent dependency conflicts between sisyphus-control and aioambient # until upper bounds for sisyphus-control have been updated @@ -148,7 +149,7 @@ # protobuf must be in package constraints for the wheel # builder to build binary wheels -protobuf==4.24.0 +protobuf==4.24.3 # faust-cchardet: Ensure we have a version we can build wheels # 2.1.18 is the first version that works with our wheel builder @@ -333,7 +334,7 @@ def process_requirements( def generate_requirements_list(reqs: dict[str, list[str]]) -> str: """Generate a pip file based on requirements.""" output = [] - for pkg, requirements in sorted(reqs.items(), key=lambda item: item[0]): + for pkg, requirements in sorted(reqs.items(), key=itemgetter(0)): for req in sorted(requirements): output.append(f"\n# {req}") @@ -425,7 +426,7 @@ def gather_constraints() -> str: *gather_recursive_requirements("default_config"), *gather_recursive_requirements("mqtt"), }, - key=lambda name: name.lower(), + key=str.lower, ) + [""] ) diff --git a/script/hassfest/__main__.py b/script/hassfest/__main__.py index 1c626ac3c5b88f..32803731ecd8e6 100644 --- a/script/hassfest/__main__.py +++ b/script/hassfest/__main__.py @@ -2,6 +2,7 @@ from __future__ import annotations import argparse +from operator import attrgetter import pathlib import sys from time import monotonic @@ -229,7 +230,7 @@ def print_integrations_status( show_fixable_errors: bool = True, ) -> None: """Print integration status.""" - for integration in sorted(integrations, key=lambda itg: itg.domain): + for integration in sorted(integrations, key=attrgetter("domain")): extra = f" - {integration.path}" if config.specific_integrations else "" print(f"Integration {integration.domain}{extra}:") for error in integration.errors: diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py index 9323b8e86c0083..acdea23444dc73 100644 --- a/script/hassfest/manifest.py +++ b/script/hassfest/manifest.py @@ -366,15 +366,19 @@ def _sort_manifest_keys(key: str) -> str: return _SORT_KEYS.get(key, key) -def sort_manifest(integration: Integration) -> bool: +def sort_manifest(integration: Integration, config: Config) -> bool: """Sort manifest.""" keys = list(integration.manifest.keys()) if (keys_sorted := sorted(keys, key=_sort_manifest_keys)) != keys: manifest = {key: integration.manifest[key] for key in keys_sorted} - integration.manifest_path.write_text(json.dumps(manifest, indent=2)) + if config.action == "generate": + integration.manifest_path.write_text(json.dumps(manifest, indent=2)) + text = "have been sorted" + else: + text = "are not sorted correctly" integration.add_error( "manifest", - "Manifest keys have been sorted: domain, name, then alphabetical order", + f"Manifest keys {text}: domain, name, then alphabetical order", ) return True return False @@ -387,9 +391,9 @@ def validate(integrations: dict[str, Integration], config: Config) -> None: for integration in integrations.values(): validate_manifest(integration, core_components_dir) if not integration.errors: - if sort_manifest(integration): + if sort_manifest(integration, config): manifests_resorted.append(integration.manifest_path) - if manifests_resorted: + if config.action == "generate" and manifests_resorted: subprocess.run( ["pre-commit", "run", "--hook-stage", "manual", "prettier", "--files"] + manifests_resorted, diff --git a/script/hassfest/model.py b/script/hassfest/model.py index e4f93c80e815f3..7df65b8221efd1 100644 --- a/script/hassfest/model.py +++ b/script/hassfest/model.py @@ -4,7 +4,7 @@ from dataclasses import dataclass, field import json import pathlib -from typing import Any +from typing import Any, Literal @dataclass @@ -26,7 +26,7 @@ class Config: specific_integrations: list[pathlib.Path] | None root: pathlib.Path - action: str + action: Literal["validate", "generate"] requirements: bool errors: list[Error] = field(default_factory=list) cache: dict[str, Any] = field(default_factory=dict) diff --git a/tests/common.py b/tests/common.py index 48bb38383c7c9d..af18640843d6ff 100644 --- a/tests/common.py +++ b/tests/common.py @@ -891,7 +891,7 @@ def __init__( unique_id=None, disabled_by=None, reason=None, - ): + ) -> None: """Initialize a mock config entry.""" kwargs = { "entry_id": entry_id or uuid_util.random_uuid_hex(), @@ -913,17 +913,15 @@ def __init__( if reason is not None: self.reason = reason - def add_to_hass(self, hass): + def add_to_hass(self, hass: HomeAssistant) -> None: """Test helper to add entry to hass.""" hass.config_entries._entries[self.entry_id] = self - hass.config_entries._domain_index.setdefault(self.domain, []).append( - self.entry_id - ) + hass.config_entries._domain_index.setdefault(self.domain, []).append(self) - def add_to_manager(self, manager): + def add_to_manager(self, manager: config_entries.ConfigEntries) -> None: """Test helper to add entry to entry manager.""" manager._entries[self.entry_id] = self - manager._domain_index.setdefault(self.domain, []).append(self.entry_id) + manager._domain_index.setdefault(self.domain, []).append(self) def patch_yaml_files(files_dict, endswith=True): diff --git a/tests/components/aemet/test_sensor.py b/tests/components/aemet/test_sensor.py index 4d61dde34fc0f7..7b6f02f8b06fa6 100644 --- a/tests/components/aemet/test_sensor.py +++ b/tests/components/aemet/test_sensor.py @@ -6,7 +6,6 @@ ATTR_CONDITION_PARTLYCLOUDY, ATTR_CONDITION_SNOWY, ) -from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant import homeassistant.util.dt as dt_util @@ -26,9 +25,6 @@ async def test_aemet_forecast_create_sensors( state = hass.states.get("sensor.aemet_daily_forecast_condition") assert state.state == ATTR_CONDITION_PARTLYCLOUDY - state = hass.states.get("sensor.aemet_daily_forecast_precipitation") - assert state.state == STATE_UNKNOWN - state = hass.states.get("sensor.aemet_daily_forecast_precipitation_probability") assert state.state == "30" @@ -70,6 +66,9 @@ async def test_aemet_forecast_create_sensors( state = hass.states.get("sensor.aemet_hourly_forecast_wind_bearing") assert state is None + state = hass.states.get("sensor.aemet_hourly_forecast_wind_max_speed") + assert state is None + state = hass.states.get("sensor.aemet_hourly_forecast_wind_speed") assert state is None diff --git a/tests/components/aemet/test_weather.py b/tests/components/aemet/test_weather.py index ddcc29698fdf8e..d0042faaaa0640 100644 --- a/tests/components/aemet/test_weather.py +++ b/tests/components/aemet/test_weather.py @@ -26,6 +26,7 @@ ATTR_WEATHER_PRESSURE, ATTR_WEATHER_TEMPERATURE, ATTR_WEATHER_WIND_BEARING, + ATTR_WEATHER_WIND_GUST_SPEED, ATTR_WEATHER_WIND_SPEED, DOMAIN as WEATHER_DOMAIN, SERVICE_GET_FORECAST, @@ -58,6 +59,7 @@ async def test_aemet_weather( assert state.attributes.get(ATTR_WEATHER_PRESSURE) == 1004.4 # 100440.0 Pa -> hPa assert state.attributes.get(ATTR_WEATHER_TEMPERATURE) == -0.7 assert state.attributes.get(ATTR_WEATHER_WIND_BEARING) == 90.0 + assert state.attributes.get(ATTR_WEATHER_WIND_GUST_SPEED) == 24.0 assert state.attributes.get(ATTR_WEATHER_WIND_SPEED) == 15.0 # 4.17 m/s -> km/h forecast = state.attributes.get(ATTR_FORECAST)[0] assert forecast.get(ATTR_FORECAST_CONDITION) == ATTR_CONDITION_PARTLYCLOUDY @@ -101,6 +103,7 @@ async def test_aemet_weather_legacy( assert state.attributes.get(ATTR_WEATHER_PRESSURE) == 1004.4 # 100440.0 Pa -> hPa assert state.attributes.get(ATTR_WEATHER_TEMPERATURE) == -0.7 assert state.attributes.get(ATTR_WEATHER_WIND_BEARING) == 90.0 + assert state.attributes.get(ATTR_WEATHER_WIND_GUST_SPEED) == 24.0 assert state.attributes.get(ATTR_WEATHER_WIND_SPEED) == 15.0 # 4.17 m/s -> km/h forecast = state.attributes.get(ATTR_FORECAST)[0] assert forecast.get(ATTR_FORECAST_CONDITION) == ATTR_CONDITION_PARTLYCLOUDY diff --git a/tests/components/airly/test_init.py b/tests/components/airly/test_init.py index 9b69607e6aa492..0a3ea927446b8a 100644 --- a/tests/components/airly/test_init.py +++ b/tests/components/airly/test_init.py @@ -5,8 +5,8 @@ import pytest from homeassistant.components.air_quality import DOMAIN as AIR_QUALITY_PLATFORM -from homeassistant.components.airly import set_update_interval from homeassistant.components.airly.const import DOMAIN +from homeassistant.components.airly.coordinator import set_update_interval from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant diff --git a/tests/components/airthings_ble/__init__.py b/tests/components/airthings_ble/__init__.py index 0dd78718a30eaa..da0c312bf28bd4 100644 --- a/tests/components/airthings_ble/__init__.py +++ b/tests/components/airthings_ble/__init__.py @@ -5,8 +5,11 @@ from airthings_ble import AirthingsBluetoothDeviceData, AirthingsDevice +from homeassistant.components.airthings_ble.const import DOMAIN from homeassistant.components.bluetooth.models import BluetoothServiceInfoBleak +from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH +from tests.common import MockConfigEntry, MockEntity from tests.components.bluetooth import generate_advertisement_data, generate_ble_device @@ -36,18 +39,52 @@ def patch_airthings_ble(return_value=AirthingsDevice, side_effect=None): ) +def patch_airthings_device_update(): + """Patch airthings-ble device.""" + return patch( + "homeassistant.components.airthings_ble.AirthingsBluetoothDeviceData.update_device", + return_value=WAVE_DEVICE_INFO, + ) + + WAVE_SERVICE_INFO = BluetoothServiceInfoBleak( name="cc-cc-cc-cc-cc-cc", address="cc:cc:cc:cc:cc:cc", + device=generate_ble_device( + address="cc:cc:cc:cc:cc:cc", + name="Airthings Wave+", + ), rssi=-61, manufacturer_data={820: b"\xe4/\xa5\xae\t\x00"}, - service_data={}, - service_uuids=["b42e1c08-ade7-11e4-89d3-123b93f75cba"], + service_data={ + # Sensor data + "b42e2a68-ade7-11e4-89d3-123b93f75cba": bytearray( + b"\x01\x02\x03\x04\x00\x05\x00\x06\x00\x07\x00\x08\x00\x09\x00\x0A" + ), + # Manufacturer + "00002a29-0000-1000-8000-00805f9b34fb": bytearray(b"Airthings AS"), + # Model + "00002a24-0000-1000-8000-00805f9b34fb": bytearray(b"2930"), + # Identifier + "00002a25-0000-1000-8000-00805f9b34fb": bytearray(b"123456"), + # SW Version + "00002a26-0000-1000-8000-00805f9b34fb": bytearray(b"G-BLE-1.5.3-master+0"), + # HW Version + "00002a27-0000-1000-8000-00805f9b34fb": bytearray(b"REV A"), + # Command + "b42e2d06-ade7-11e4-89d3-123b93f75cba": bytearray(b"\x00"), + }, + service_uuids=[ + "b42e1c08-ade7-11e4-89d3-123b93f75cba", + "b42e2a68-ade7-11e4-89d3-123b93f75cba", + "00002a29-0000-1000-8000-00805f9b34fb", + "00002a24-0000-1000-8000-00805f9b34fb", + "00002a25-0000-1000-8000-00805f9b34fb", + "00002a26-0000-1000-8000-00805f9b34fb", + "00002a27-0000-1000-8000-00805f9b34fb", + "b42e2d06-ade7-11e4-89d3-123b93f75cba", + ], source="local", - device=generate_ble_device( - "cc:cc:cc:cc:cc:cc", - "cc-cc-cc-cc-cc-cc", - ), advertisement=generate_advertisement_data( manufacturer_data={820: b"\xe4/\xa5\xae\t\x00"}, service_uuids=["b42e1c08-ade7-11e4-89d3-123b93f75cba"], @@ -99,3 +136,62 @@ def patch_airthings_ble(return_value=AirthingsDevice, side_effect=None): }, address="cc:cc:cc:cc:cc:cc", ) + +TEMPERATURE_V1 = MockEntity( + unique_id="Airthings Wave Plus 123456_temperature", + name="Airthings Wave Plus 123456 Temperature", +) + +HUMIDITY_V2 = MockEntity( + unique_id="Airthings Wave Plus (123456)_humidity", + name="Airthings Wave Plus (123456) Humidity", +) + +CO2_V1 = MockEntity( + unique_id="Airthings Wave Plus 123456_co2", + name="Airthings Wave Plus 123456 CO2", +) + +CO2_V2 = MockEntity( + unique_id="Airthings Wave Plus (123456)_co2", + name="Airthings Wave Plus (123456) CO2", +) + +VOC_V1 = MockEntity( + unique_id="Airthings Wave Plus 123456_voc", + name="Airthings Wave Plus 123456 CO2", +) + +VOC_V2 = MockEntity( + unique_id="Airthings Wave Plus (123456)_voc", + name="Airthings Wave Plus (123456) VOC", +) + +VOC_V3 = MockEntity( + unique_id="cc:cc:cc:cc:cc:cc_voc", + name="Airthings Wave Plus (123456) VOC", +) + + +def create_entry(hass): + """Create a config entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=WAVE_SERVICE_INFO.address, + title="Airthings Wave Plus (123456)", + ) + entry.add_to_hass(hass) + return entry + + +def create_device(hass, entry): + """Create a device for the given entry.""" + device_registry = hass.helpers.device_registry.async_get(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections={(CONNECTION_BLUETOOTH, WAVE_SERVICE_INFO.address)}, + manufacturer="Airthings AS", + name="Airthings Wave Plus (123456)", + model="Wave Plus", + ) + return device diff --git a/tests/components/airthings_ble/test_sensor.py b/tests/components/airthings_ble/test_sensor.py new file mode 100644 index 00000000000000..1bf036b735d75d --- /dev/null +++ b/tests/components/airthings_ble/test_sensor.py @@ -0,0 +1,212 @@ +"""Test the Airthings Wave sensor.""" +import logging + +from homeassistant.components.airthings_ble.const import DOMAIN +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from tests.components.airthings_ble import ( + CO2_V1, + CO2_V2, + HUMIDITY_V2, + TEMPERATURE_V1, + VOC_V1, + VOC_V2, + VOC_V3, + WAVE_DEVICE_INFO, + WAVE_SERVICE_INFO, + create_device, + create_entry, + patch_airthings_device_update, +) +from tests.components.bluetooth import inject_bluetooth_service_info + +_LOGGER = logging.getLogger(__name__) + + +async def test_migration_from_v1_to_v3_unique_id(hass: HomeAssistant): + """Verify that we can migrate from v1 (pre 2023.9.0) to the latest unique id format.""" + entry = create_entry(hass) + device = create_device(hass, entry) + + assert entry is not None + assert device is not None + + new_unique_id = f"{WAVE_DEVICE_INFO.address}_temperature" + + entity_registry = hass.helpers.entity_registry.async_get(hass) + + sensor = entity_registry.async_get_or_create( + domain=DOMAIN, + platform=Platform.SENSOR, + unique_id=TEMPERATURE_V1.unique_id, + config_entry=entry, + device_id=device.id, + ) + + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 0 + + inject_bluetooth_service_info( + hass, + WAVE_SERVICE_INFO, + ) + + await hass.async_block_till_done() + + with patch_airthings_device_update(): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) > 0 + + assert entity_registry.async_get(sensor.entity_id).unique_id == new_unique_id + + +async def test_migration_from_v2_to_v3_unique_id(hass: HomeAssistant): + """Verify that we can migrate from v2 (introduced in 2023.9.0) to the latest unique id format.""" + entry = create_entry(hass) + device = create_device(hass, entry) + + assert entry is not None + assert device is not None + + entity_registry = hass.helpers.entity_registry.async_get(hass) + + await hass.async_block_till_done() + + sensor = entity_registry.async_get_or_create( + domain=DOMAIN, + platform=Platform.SENSOR, + unique_id=HUMIDITY_V2.unique_id, + config_entry=entry, + device_id=device.id, + ) + + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 0 + + inject_bluetooth_service_info( + hass, + WAVE_SERVICE_INFO, + ) + + await hass.async_block_till_done() + + with patch_airthings_device_update(): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) > 0 + + # Migration should happen, v2 unique id should be updated to the new format + new_unique_id = f"{WAVE_DEVICE_INFO.address}_humidity" + assert entity_registry.async_get(sensor.entity_id).unique_id == new_unique_id + + +async def test_migration_from_v1_and_v2_to_v3_unique_id(hass: HomeAssistant): + """Test if migration works when we have both v1 (pre 2023.9.0) and v2 (introduced in 2023.9.0) unique ids.""" + entry = create_entry(hass) + device = create_device(hass, entry) + + assert entry is not None + assert device is not None + + entity_registry = hass.helpers.entity_registry.async_get(hass) + + await hass.async_block_till_done() + + v2 = entity_registry.async_get_or_create( + domain=DOMAIN, + platform=Platform.SENSOR, + unique_id=CO2_V2.unique_id, + config_entry=entry, + device_id=device.id, + ) + + v1 = entity_registry.async_get_or_create( + domain=DOMAIN, + platform=Platform.SENSOR, + unique_id=CO2_V1.unique_id, + config_entry=entry, + device_id=device.id, + ) + + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 0 + + inject_bluetooth_service_info( + hass, + WAVE_SERVICE_INFO, + ) + + await hass.async_block_till_done() + + with patch_airthings_device_update(): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) > 0 + + # Migration should happen, v1 unique id should be updated to the new format + new_unique_id = f"{WAVE_DEVICE_INFO.address}_co2" + assert entity_registry.async_get(v1.entity_id).unique_id == new_unique_id + assert entity_registry.async_get(v2.entity_id).unique_id == CO2_V2.unique_id + + +async def test_migration_with_all_unique_ids(hass: HomeAssistant): + """Test if migration works when we have all unique ids.""" + entry = create_entry(hass) + device = create_device(hass, entry) + + assert entry is not None + assert device is not None + + entity_registry = hass.helpers.entity_registry.async_get(hass) + + await hass.async_block_till_done() + + v1 = entity_registry.async_get_or_create( + domain=DOMAIN, + platform=Platform.SENSOR, + unique_id=VOC_V1.unique_id, + config_entry=entry, + device_id=device.id, + ) + + v2 = entity_registry.async_get_or_create( + domain=DOMAIN, + platform=Platform.SENSOR, + unique_id=VOC_V2.unique_id, + config_entry=entry, + device_id=device.id, + ) + + v3 = entity_registry.async_get_or_create( + domain=DOMAIN, + platform=Platform.SENSOR, + unique_id=VOC_V3.unique_id, + config_entry=entry, + device_id=device.id, + ) + + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 0 + + inject_bluetooth_service_info( + hass, + WAVE_SERVICE_INFO, + ) + + await hass.async_block_till_done() + + with patch_airthings_device_update(): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) > 0 + + # No migration should happen, unique id should be the same as before + assert entity_registry.async_get(v1.entity_id).unique_id == VOC_V1.unique_id + assert entity_registry.async_get(v2.entity_id).unique_id == VOC_V2.unique_id + assert entity_registry.async_get(v3.entity_id).unique_id == VOC_V3.unique_id diff --git a/tests/components/airzone/test_water_heater.py b/tests/components/airzone/test_water_heater.py new file mode 100644 index 00000000000000..a1157192f23559 --- /dev/null +++ b/tests/components/airzone/test_water_heater.py @@ -0,0 +1,228 @@ +"""The water heater tests for the Airzone platform.""" +from unittest.mock import patch + +from aioairzone.const import ( + API_ACS_ON, + API_ACS_POWER_MODE, + API_ACS_SET_POINT, + API_DATA, + API_SYSTEM_ID, +) +from aioairzone.exceptions import AirzoneError +import pytest + +from homeassistant.components.water_heater import ( + ATTR_CURRENT_TEMPERATURE, + ATTR_MAX_TEMP, + ATTR_MIN_TEMP, + ATTR_OPERATION_MODE, + DOMAIN as WATER_HEATER_DOMAIN, + SERVICE_SET_OPERATION_MODE, + SERVICE_SET_TEMPERATURE, + STATE_ECO, + STATE_PERFORMANCE, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_TEMPERATURE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from .util import async_init_integration + + +async def test_airzone_create_water_heater(hass: HomeAssistant) -> None: + """Test creation of water heater.""" + + await async_init_integration(hass) + + state = hass.states.get("water_heater.airzone_dhw") + assert state.state == STATE_ECO + assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 43 + assert state.attributes[ATTR_MAX_TEMP] == 75 + assert state.attributes[ATTR_MIN_TEMP] == 30 + assert state.attributes[ATTR_TEMPERATURE] == 45 + + +async def test_airzone_water_heater_turn_on_off(hass: HomeAssistant) -> None: + """Test turning on/off.""" + + await async_init_integration(hass) + + HVAC_MOCK = { + API_DATA: { + API_SYSTEM_ID: 0, + API_ACS_ON: 0, + } + } + with patch( + "homeassistant.components.airzone.AirzoneLocalApi.put_hvac", + return_value=HVAC_MOCK, + ): + await hass.services.async_call( + WATER_HEATER_DOMAIN, + SERVICE_TURN_OFF, + { + ATTR_ENTITY_ID: "water_heater.airzone_dhw", + }, + blocking=True, + ) + + state = hass.states.get("water_heater.airzone_dhw") + assert state.state == STATE_OFF + + HVAC_MOCK = { + API_DATA: { + API_SYSTEM_ID: 0, + API_ACS_ON: 1, + } + } + with patch( + "homeassistant.components.airzone.AirzoneLocalApi.put_hvac", + return_value=HVAC_MOCK, + ): + await hass.services.async_call( + WATER_HEATER_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "water_heater.airzone_dhw", + }, + blocking=True, + ) + + state = hass.states.get("water_heater.airzone_dhw") + assert state.state == STATE_ECO + + +async def test_airzone_water_heater_set_operation(hass: HomeAssistant) -> None: + """Test setting the Operation mode.""" + + await async_init_integration(hass) + + HVAC_MOCK_1 = { + API_DATA: { + API_SYSTEM_ID: 0, + API_ACS_ON: 0, + } + } + with patch( + "homeassistant.components.airzone.AirzoneLocalApi.put_hvac", + return_value=HVAC_MOCK_1, + ): + await hass.services.async_call( + WATER_HEATER_DOMAIN, + SERVICE_SET_OPERATION_MODE, + { + ATTR_ENTITY_ID: "water_heater.airzone_dhw", + ATTR_OPERATION_MODE: STATE_OFF, + }, + blocking=True, + ) + + state = hass.states.get("water_heater.airzone_dhw") + assert state.state == STATE_OFF + + HVAC_MOCK_2 = { + API_DATA: { + API_SYSTEM_ID: 0, + API_ACS_ON: 1, + API_ACS_POWER_MODE: 1, + } + } + with patch( + "homeassistant.components.airzone.AirzoneLocalApi.put_hvac", + return_value=HVAC_MOCK_2, + ): + await hass.services.async_call( + WATER_HEATER_DOMAIN, + SERVICE_SET_OPERATION_MODE, + { + ATTR_ENTITY_ID: "water_heater.airzone_dhw", + ATTR_OPERATION_MODE: STATE_PERFORMANCE, + }, + blocking=True, + ) + + state = hass.states.get("water_heater.airzone_dhw") + assert state.state == STATE_PERFORMANCE + + HVAC_MOCK_3 = { + API_DATA: { + API_SYSTEM_ID: 0, + API_ACS_ON: 1, + API_ACS_POWER_MODE: 0, + } + } + with patch( + "homeassistant.components.airzone.AirzoneLocalApi.put_hvac", + return_value=HVAC_MOCK_3, + ): + await hass.services.async_call( + WATER_HEATER_DOMAIN, + SERVICE_SET_OPERATION_MODE, + { + ATTR_ENTITY_ID: "water_heater.airzone_dhw", + ATTR_OPERATION_MODE: STATE_ECO, + }, + blocking=True, + ) + + state = hass.states.get("water_heater.airzone_dhw") + assert state.state == STATE_ECO + + +async def test_airzone_water_heater_set_temp(hass: HomeAssistant) -> None: + """Test setting the target temperature.""" + + HVAC_MOCK = { + API_DATA: { + API_SYSTEM_ID: 0, + API_ACS_SET_POINT: 35, + } + } + + await async_init_integration(hass) + + with patch( + "homeassistant.components.airzone.AirzoneLocalApi.put_hvac", + return_value=HVAC_MOCK, + ): + await hass.services.async_call( + WATER_HEATER_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: "water_heater.airzone_dhw", + ATTR_TEMPERATURE: 35, + }, + blocking=True, + ) + + state = hass.states.get("water_heater.airzone_dhw") + assert state.attributes[ATTR_TEMPERATURE] == 35 + + +async def test_airzone_water_heater_set_temp_error(hass: HomeAssistant) -> None: + """Test error when setting the target temperature.""" + + await async_init_integration(hass) + + with patch( + "homeassistant.components.airzone.AirzoneLocalApi.put_hvac", + side_effect=AirzoneError, + ), pytest.raises(HomeAssistantError): + await hass.services.async_call( + WATER_HEATER_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: "water_heater.airzone_dhw", + ATTR_TEMPERATURE: 80, + }, + blocking=True, + ) + + state = hass.states.get("water_heater.airzone_dhw") + assert state.attributes[ATTR_TEMPERATURE] == 45 diff --git a/tests/components/aladdin_connect/conftest.py b/tests/components/aladdin_connect/conftest.py index 250548e7ef20dd..3f5fc4f8f976ee 100644 --- a/tests/components/aladdin_connect/conftest.py +++ b/tests/components/aladdin_connect/conftest.py @@ -12,6 +12,10 @@ "link_status": "Connected", "serial": "12345", "model": "02", + "rssi": -67, + "ble_strength": 0, + "vendor": "GENIE", + "battery_level": 0, } @@ -35,7 +39,7 @@ def fixture_mock_aladdinconnect_api(): mock_opener.async_get_ble_strength = AsyncMock(return_value="-45") mock_opener.get_ble_strength.return_value = "-45" mock_opener.get_doors = AsyncMock(return_value=[DEVICE_CONFIG_OPEN]) - + mock_opener.doors = [DEVICE_CONFIG_OPEN] mock_opener.register_callback = mock.Mock(return_value=True) mock_opener.open_door = AsyncMock(return_value=True) mock_opener.close_door = AsyncMock(return_value=True) diff --git a/tests/components/aladdin_connect/snapshots/test_diagnostics.ambr b/tests/components/aladdin_connect/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000000..8f96567a49f1b3 --- /dev/null +++ b/tests/components/aladdin_connect/snapshots/test_diagnostics.ambr @@ -0,0 +1,20 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'doors': list([ + dict({ + 'battery_level': 0, + 'ble_strength': 0, + 'device_id': '**REDACTED**', + 'door_number': 1, + 'link_status': 'Connected', + 'model': '02', + 'name': 'home', + 'rssi': -67, + 'serial': '**REDACTED**', + 'status': 'open', + 'vendor': 'GENIE', + }), + ]), + }) +# --- diff --git a/tests/components/aladdin_connect/test_cover.py b/tests/components/aladdin_connect/test_cover.py index eb617b959a5c11..ba82ec6589a60d 100644 --- a/tests/components/aladdin_connect/test_cover.py +++ b/tests/components/aladdin_connect/test_cover.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock, MagicMock, patch from AIOAladdinConnect import session_manager +import pytest from homeassistant.components.aladdin_connect.const import DOMAIN from homeassistant.components.aladdin_connect.cover import SCAN_INTERVAL @@ -19,6 +20,7 @@ STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow @@ -123,6 +125,17 @@ async def test_cover_operation( ) assert hass.states.get("cover.home").state == STATE_OPEN + mock_aladdinconnect_api.open_door.return_value = False + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: "cover.home"}, + blocking=True, + ) + + mock_aladdinconnect_api.open_door.return_value = True + mock_aladdinconnect_api.async_get_door_status = AsyncMock(return_value=STATE_CLOSED) mock_aladdinconnect_api.get_door_status.return_value = STATE_CLOSED @@ -140,6 +153,17 @@ async def test_cover_operation( assert hass.states.get("cover.home").state == STATE_CLOSED + mock_aladdinconnect_api.close_door.return_value = False + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: "cover.home"}, + blocking=True, + ) + + mock_aladdinconnect_api.close_door.return_value = True + mock_aladdinconnect_api.async_get_door_status = AsyncMock( return_value=STATE_CLOSING ) diff --git a/tests/components/aladdin_connect/test_diagnostics.py b/tests/components/aladdin_connect/test_diagnostics.py new file mode 100644 index 00000000000000..4d5fe9037981db --- /dev/null +++ b/tests/components/aladdin_connect/test_diagnostics.py @@ -0,0 +1,40 @@ +"""Test AccuWeather diagnostics.""" +from unittest.mock import MagicMock, patch + +from syrupy import SnapshotAssertion + +from homeassistant.components.aladdin_connect.const import DOMAIN +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 + +YAML_CONFIG = {"username": "test-user", "password": "test-password"} + + +async def test_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, + mock_aladdinconnect_api: MagicMock, +) -> None: + """Test config entry diagnostics.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + data=YAML_CONFIG, + unique_id="test-id", + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.aladdin_connect.AladdinConnectClient", + return_value=mock_aladdinconnect_api, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + + assert result == snapshot diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index c42ea0a0f6a549..bbdf3efeb5fee9 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -2471,6 +2471,75 @@ async def test_thermostat(hass: HomeAssistant) -> None: assert call.data["preset_mode"] == "eco" +async def test_no_current_target_temp_adjusting_temp(hass: HomeAssistant) -> None: + """Test thermostat adjusting temp with no initial target temperature.""" + hass.config.units = US_CUSTOMARY_SYSTEM + device = ( + "climate.test_thermostat", + "cool", + { + "temperature": None, + "target_temp_high": None, + "target_temp_low": None, + "current_temperature": 75.0, + "friendly_name": "Test Thermostat", + "supported_features": 1 | 2 | 4 | 128, + "hvac_modes": ["off", "heat", "cool", "auto", "dry", "fan_only"], + "preset_mode": None, + "preset_modes": ["eco"], + "min_temp": 50, + "max_temp": 90, + }, + ) + appliance = await discovery_test(device, hass) + + assert appliance["endpointId"] == "climate#test_thermostat" + assert appliance["displayCategories"][0] == "THERMOSTAT" + assert appliance["friendlyName"] == "Test Thermostat" + + capabilities = assert_endpoint_capabilities( + appliance, + "Alexa.PowerController", + "Alexa.ThermostatController", + "Alexa.TemperatureSensor", + "Alexa.EndpointHealth", + "Alexa", + ) + + properties = await reported_properties(hass, "climate#test_thermostat") + properties.assert_equal("Alexa.ThermostatController", "thermostatMode", "COOL") + properties.assert_not_has_property( + "Alexa.ThermostatController", + "targetSetpoint", + ) + properties.assert_equal( + "Alexa.TemperatureSensor", "temperature", {"value": 75.0, "scale": "FAHRENHEIT"} + ) + + thermostat_capability = get_capability(capabilities, "Alexa.ThermostatController") + assert thermostat_capability is not None + configuration = thermostat_capability["configuration"] + assert configuration["supportsScheduling"] is False + + supported_modes = ["OFF", "HEAT", "COOL", "AUTO", "ECO", "CUSTOM"] + for mode in supported_modes: + assert mode in configuration["supportedModes"] + + # Adjust temperature where target temp is not set + msg = await assert_request_fails( + "Alexa.ThermostatController", + "AdjustTargetTemperature", + "climate#test_thermostat", + "climate.set_temperature", + hass, + payload={"targetSetpointDelta": {"value": -5.0, "scale": "KELVIN"}}, + ) + assert msg["event"]["payload"]["type"] == "INVALID_TARGET_STATE" + assert msg["event"]["payload"]["message"] == ( + "The current target temperature is not set, cannot adjust target temperature" + ) + + async def test_thermostat_dual(hass: HomeAssistant) -> None: """Test thermostat discovery with auto mode, with upper and lower target temperatures.""" hass.config.units = US_CUSTOMARY_SYSTEM diff --git a/tests/components/androidtv_remote/test_config_flow.py b/tests/components/androidtv_remote/test_config_flow.py index a2792efb0f31a2..fb4bc829160afe 100644 --- a/tests/components/androidtv_remote/test_config_flow.py +++ b/tests/components/androidtv_remote/test_config_flow.py @@ -1,4 +1,5 @@ """Test the Android TV Remote config flow.""" +from ipaddress import ip_address from unittest.mock import AsyncMock, MagicMock from androidtvremote2 import CannotConnect, ConnectionClosed, InvalidAuth @@ -431,8 +432,8 @@ async def test_zeroconf_flow_success( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host=host, - addresses=[host], + ip_address=ip_address(host), + ip_addresses=[ip_address(host)], port=6466, hostname=host, type="mock_type", @@ -509,8 +510,8 @@ async def test_zeroconf_flow_cannot_connect( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host=host, - addresses=[host], + ip_address=ip_address(host), + ip_addresses=[ip_address(host)], port=6466, hostname=host, type="mock_type", @@ -560,8 +561,8 @@ async def test_zeroconf_flow_pairing_invalid_auth( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host=host, - addresses=[host], + ip_address=ip_address(host), + ip_addresses=[ip_address(host)], port=6466, hostname=host, type="mock_type", @@ -643,8 +644,8 @@ async def test_zeroconf_flow_already_configured_host_changed_reloads_entry( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host=host, - addresses=[host], + ip_address=ip_address(host), + ip_addresses=[ip_address(host)], port=6466, hostname=host, type="mock_type", @@ -696,8 +697,8 @@ async def test_zeroconf_flow_already_configured_host_not_changed_no_reload_entry DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host=host, - addresses=[host], + ip_address=ip_address(host), + ip_addresses=[ip_address(host)], port=6466, hostname=host, type="mock_type", @@ -729,8 +730,8 @@ async def test_zeroconf_flow_abort_if_mac_is_missing( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host=host, - addresses=[host], + ip_address=ip_address(host), + ip_addresses=[ip_address(host)], port=6466, hostname=host, type="mock_type", diff --git a/tests/components/api/test_init.py b/tests/components/api/test_init.py index 116529b02a4515..2d5705403413eb 100644 --- a/tests/components/api/test_init.py +++ b/tests/components/api/test_init.py @@ -9,6 +9,7 @@ import voluptuous as vol from homeassistant import const +from homeassistant.auth.models import Credentials from homeassistant.auth.providers.legacy_api_password import ( LegacyApiPasswordAuthProvider, ) @@ -17,7 +18,7 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.common import MockUser, async_mock_service +from tests.common import CLIENT_ID, MockUser, async_mock_service from tests.typing import ClientSessionGenerator @@ -96,6 +97,28 @@ async def test_api_state_change_of_non_existing_entity( assert hass.states.get("test_entity.that_does_not_exist").state == new_state +async def test_api_state_change_with_bad_entity_id( + hass: HomeAssistant, mock_api_client: TestClient +) -> None: + """Test if API sends appropriate error if we omit state.""" + resp = await mock_api_client.post( + "/api/states/bad.entity.id", json={"state": "new_state"} + ) + + assert resp.status == HTTPStatus.BAD_REQUEST + + +async def test_api_state_change_with_bad_state( + hass: HomeAssistant, mock_api_client: TestClient +) -> None: + """Test if API sends appropriate error if we omit state.""" + resp = await mock_api_client.post( + "/api/states/test.test", json={"state": "x" * 256} + ) + + assert resp.status == HTTPStatus.BAD_REQUEST + + async def test_api_state_change_with_bad_data( hass: HomeAssistant, mock_api_client: TestClient ) -> None: @@ -569,11 +592,43 @@ async def test_event_stream_requires_admin( assert resp.status == HTTPStatus.UNAUTHORIZED -async def test_states_view_filters( +async def test_states( hass: HomeAssistant, mock_api_client: TestClient, hass_admin_user: MockUser +) -> None: + """Test fetching all states as admin.""" + hass.states.async_set("test.entity", "hello") + hass.states.async_set("test.entity2", "hello") + resp = await mock_api_client.get(const.URL_API_STATES) + assert resp.status == HTTPStatus.OK + json = await resp.json() + assert len(json) == 2 + assert json[0]["entity_id"] == "test.entity" + assert json[1]["entity_id"] == "test.entity2" + + +async def test_states_view_filters( + hass: HomeAssistant, + hass_read_only_user: MockUser, + hass_client: ClientSessionGenerator, ) -> None: """Test filtering only visible states.""" - hass_admin_user.mock_policy({"entities": {"entity_ids": {"test.entity": True}}}) + assert not hass_read_only_user.is_admin + hass_read_only_user.mock_policy({"entities": {"entity_ids": {"test.entity": True}}}) + await async_setup_component(hass, "api", {}) + read_only_user_credential = Credentials( + id="mock-read-only-credential-id", + auth_provider_type="homeassistant", + auth_provider_id=None, + data={"username": "readonly"}, + is_new=False, + ) + await hass.auth.async_link_user(hass_read_only_user, read_only_user_credential) + + refresh_token = await hass.auth.async_create_refresh_token( + hass_read_only_user, CLIENT_ID, credential=read_only_user_credential + ) + token = hass.auth.async_create_access_token(refresh_token) + mock_api_client = await hass_client(token) hass.states.async_set("test.entity", "hello") hass.states.async_set("test.not_visible_entity", "invisible") resp = await mock_api_client.get(const.URL_API_STATES) diff --git a/tests/components/apple_tv/test_config_flow.py b/tests/components/apple_tv/test_config_flow.py index 6256d1dde9ca2c..513c21f7ce5e4e 100644 --- a/tests/components/apple_tv/test_config_flow.py +++ b/tests/components/apple_tv/test_config_flow.py @@ -1,5 +1,5 @@ """Test config flow.""" -from ipaddress import IPv4Address +from ipaddress import IPv4Address, ip_address from unittest.mock import ANY, patch from pyatv import exceptions @@ -21,8 +21,8 @@ from tests.common import MockConfigEntry DMAP_SERVICE = zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["127.0.0.1"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", port=None, type="_touch-able._tcp.local.", @@ -32,8 +32,8 @@ RAOP_SERVICE = zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["127.0.0.1"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", port=None, type="_raop._tcp.local.", @@ -558,8 +558,8 @@ async def test_zeroconf_unsupported_service_aborts(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["127.0.0.1"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="mock_name", port=None, @@ -579,8 +579,8 @@ async def test_zeroconf_add_mrp_device( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="127.0.0.2", - addresses=["127.0.0.2"], + ip_address=ip_address("127.0.0.2"), + ip_addresses=[ip_address("127.0.0.2")], hostname="mock_hostname", port=None, name="Kitchen", @@ -594,8 +594,8 @@ async def test_zeroconf_add_mrp_device( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["127.0.0.1"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", port=None, name="Kitchen", @@ -836,8 +836,8 @@ async def test_zeroconf_abort_if_other_in_progress( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["127.0.0.1"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", port=None, type="_airplay._tcp.local.", @@ -859,8 +859,8 @@ async def test_zeroconf_abort_if_other_in_progress( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["127.0.0.1"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", port=None, type="_mediaremotetv._tcp.local.", @@ -885,8 +885,8 @@ async def test_zeroconf_missing_device_during_protocol_resolve( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["127.0.0.1"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", port=None, type="_airplay._tcp.local.", @@ -907,8 +907,8 @@ async def test_zeroconf_missing_device_during_protocol_resolve( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["127.0.0.1"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", port=None, type="_mediaremotetv._tcp.local.", @@ -943,8 +943,8 @@ async def test_zeroconf_additional_protocol_resolve_failure( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["127.0.0.1"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", port=None, type="_airplay._tcp.local.", @@ -965,8 +965,8 @@ async def test_zeroconf_additional_protocol_resolve_failure( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["127.0.0.1"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", port=None, type="_mediaremotetv._tcp.local.", @@ -1003,8 +1003,8 @@ async def test_zeroconf_pair_additionally_found_protocols( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["127.0.0.1"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", port=None, type="_airplay._tcp.local.", @@ -1046,8 +1046,8 @@ async def test_zeroconf_pair_additionally_found_protocols( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["127.0.0.1"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", port=None, type="_mediaremotetv._tcp.local.", @@ -1158,8 +1158,8 @@ async def test_zeroconf_rejects_ipv6(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="fd00::b27c:63bb:cc85:4ea0", - addresses=["fd00::b27c:63bb:cc85:4ea0"], + ip_address=ip_address("fd00::b27c:63bb:cc85:4ea0"), + ip_addresses=[ip_address("fd00::b27c:63bb:cc85:4ea0")], hostname="mock_hostname", port=None, type="_touch-able._tcp.local.", diff --git a/tests/components/assist_pipeline/test_websocket.py b/tests/components/assist_pipeline/test_websocket.py index ca631be4549bb5..a7ba9063b3fb61 100644 --- a/tests/components/assist_pipeline/test_websocket.py +++ b/tests/components/assist_pipeline/test_websocket.py @@ -1633,3 +1633,29 @@ async def test_list_pipeline_languages( msg = await client.receive_json() assert msg["success"] assert msg["result"] == {"languages": ["en"]} + + +async def test_list_pipeline_languages_with_aliases( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_components, +) -> None: + """Test listing pipeline languages using aliases.""" + client = await hass_ws_client(hass) + + with patch( + "homeassistant.components.conversation.async_get_conversation_languages", + return_value={"he", "nb"}, + ), patch( + "homeassistant.components.stt.async_get_speech_to_text_languages", + return_value={"he", "no"}, + ), patch( + "homeassistant.components.tts.async_get_text_to_speech_languages", + return_value={"iw", "nb"}, + ): + await client.send_json_auto_id({"type": "assist_pipeline/language/list"}) + + # result + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == {"languages": ["he", "nb"]} diff --git a/tests/components/awair/const.py b/tests/components/awair/const.py index cead20d10afb52..f24eaeb971d821 100644 --- a/tests/components/awair/const.py +++ b/tests/components/awair/const.py @@ -1,5 +1,7 @@ """Constants used in Awair tests.""" +from ipaddress import ip_address + from homeassistant.components import zeroconf from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST @@ -9,8 +11,8 @@ CLOUD_UNIQUE_ID = "foo@bar.com" LOCAL_UNIQUE_ID = "00:B0:D0:63:C2:26" ZEROCONF_DISCOVERY = zeroconf.ZeroconfServiceInfo( - host="192.0.2.5", - addresses=["192.0.2.5"], + ip_address=ip_address("192.0.2.5"), + ip_addresses=[ip_address("192.0.2.5")], hostname="mock_hostname", name="awair12345", port=None, diff --git a/tests/components/axis/test_config_flow.py b/tests/components/axis/test_config_flow.py index d535b4bcb1f610..06fad5329ea3fd 100644 --- a/tests/components/axis/test_config_flow.py +++ b/tests/components/axis/test_config_flow.py @@ -1,4 +1,5 @@ """Test Axis config flow.""" +from ipaddress import ip_address from unittest.mock import patch import pytest @@ -294,8 +295,8 @@ async def test_reauth_flow_update_configuration( ( SOURCE_ZEROCONF, zeroconf.ZeroconfServiceInfo( - host=DEFAULT_HOST, - addresses=[DEFAULT_HOST], + ip_address=ip_address(DEFAULT_HOST), + ip_addresses=[ip_address(DEFAULT_HOST)], port=80, hostname=f"axis-{MAC.lower()}.local.", type="_axis-video._tcp.local.", @@ -377,8 +378,8 @@ async def test_discovery_flow( ( SOURCE_ZEROCONF, zeroconf.ZeroconfServiceInfo( - host=DEFAULT_HOST, - addresses=[DEFAULT_HOST], + ip_address=ip_address(DEFAULT_HOST), + ip_addresses=[ip_address(DEFAULT_HOST)], hostname="mock_hostname", name=f"AXIS M1065-LW - {MAC}._axis-video._tcp.local.", port=80, @@ -431,8 +432,8 @@ async def test_discovered_device_already_configured( ( SOURCE_ZEROCONF, zeroconf.ZeroconfServiceInfo( - host="2.3.4.5", - addresses=["2.3.4.5"], + ip_address=ip_address("2.3.4.5"), + ip_addresses=[ip_address("2.3.4.5")], hostname="mock_hostname", name=f"AXIS M1065-LW - {MAC}._axis-video._tcp.local.", port=8080, @@ -505,8 +506,8 @@ async def test_discovery_flow_updated_configuration( ( SOURCE_ZEROCONF, zeroconf.ZeroconfServiceInfo( - host="", - addresses=[""], + ip_address=None, + ip_addresses=[], hostname="mock_hostname", name="", port=0, @@ -554,8 +555,8 @@ async def test_discovery_flow_ignore_non_axis_device( ( SOURCE_ZEROCONF, zeroconf.ZeroconfServiceInfo( - host="169.254.3.4", - addresses=["169.254.3.4"], + ip_address=ip_address("169.254.3.4"), + ip_addresses=[ip_address("169.254.3.4")], hostname="mock_hostname", name=f"AXIS M1065-LW - {MAC}._axis-video._tcp.local.", port=80, diff --git a/tests/components/axis/test_device.py b/tests/components/axis/test_device.py index ef2cc7f448ad74..ff7ff343a06108 100644 --- a/tests/components/axis/test_device.py +++ b/tests/components/axis/test_device.py @@ -1,4 +1,5 @@ """Test Axis device.""" +from ipaddress import ip_address from unittest import mock from unittest.mock import Mock, patch @@ -117,8 +118,8 @@ async def test_update_address( await hass.config_entries.flow.async_init( AXIS_DOMAIN, data=zeroconf.ZeroconfServiceInfo( - host="2.3.4.5", - addresses=["2.3.4.5"], + ip_address=ip_address("2.3.4.5"), + ip_addresses=[ip_address("2.3.4.5")], hostname="mock_hostname", name="name", port=80, diff --git a/tests/components/baf/test_config_flow.py b/tests/components/baf/test_config_flow.py index 871e75f7c23c1d..f770db05096a36 100644 --- a/tests/components/baf/test_config_flow.py +++ b/tests/components/baf/test_config_flow.py @@ -1,5 +1,6 @@ """Test the baf config flow.""" import asyncio +from ipaddress import ip_address from unittest.mock import patch from homeassistant import config_entries @@ -87,8 +88,8 @@ async def test_zeroconf_discovery(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["127.0.0.1"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="testfan", port=None, @@ -125,8 +126,8 @@ async def test_zeroconf_updates_existing_ip(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["127.0.0.1"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="testfan", port=None, @@ -145,8 +146,8 @@ async def test_zeroconf_rejects_ipv6(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="fd00::b27c:63bb:cc85:4ea0", - addresses=["fd00::b27c:63bb:cc85:4ea0"], + ip_address=ip_address("fd00::b27c:63bb:cc85:4ea0"), + ip_addresses=[ip_address("fd00::b27c:63bb:cc85:4ea0")], hostname="mock_hostname", name="testfan", port=None, @@ -164,8 +165,8 @@ async def test_user_flow_is_not_blocked_by_discovery(hass: HomeAssistant) -> Non DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["127.0.0.1"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="testfan", port=None, diff --git a/tests/components/blebox/test_config_flow.py b/tests/components/blebox/test_config_flow.py index 0f2cfebd12e1fe..765f7af3f62a9c 100644 --- a/tests/components/blebox/test_config_flow.py +++ b/tests/components/blebox/test_config_flow.py @@ -1,4 +1,5 @@ """Test Home Assistant config flow for BleBox devices.""" +from ipaddress import ip_address from unittest.mock import DEFAULT, AsyncMock, PropertyMock, patch import blebox_uniapi @@ -211,8 +212,8 @@ async def test_flow_with_zeroconf(hass: HomeAssistant) -> None: config_flow.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="172.100.123.4", - addresses=["172.100.123.4"], + ip_address=ip_address("172.100.123.4"), + ip_addresses=[ip_address("172.100.123.4")], port=80, hostname="bbx-bbtest123456.local.", type="_bbxsrv._tcp.local.", @@ -251,8 +252,8 @@ async def test_flow_with_zeroconf_when_already_configured(hass: HomeAssistant) - config_flow.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="172.100.123.4", - addresses=["172.100.123.4"], + ip_address=ip_address("172.100.123.4"), + ip_addresses=[ip_address("172.100.123.4")], port=80, hostname="bbx-bbtest123456.local.", type="_bbxsrv._tcp.local.", @@ -275,8 +276,8 @@ async def test_flow_with_zeroconf_when_device_unsupported(hass: HomeAssistant) - config_flow.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="172.100.123.4", - addresses=["172.100.123.4"], + ip_address=ip_address("172.100.123.4"), + ip_addresses=[ip_address("172.100.123.4")], port=80, hostname="bbx-bbtest123456.local.", type="_bbxsrv._tcp.local.", @@ -301,8 +302,8 @@ async def test_flow_with_zeroconf_when_device_response_unsupported( config_flow.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="172.100.123.4", - addresses=["172.100.123.4"], + ip_address=ip_address("172.100.123.4"), + ip_addresses=[ip_address("172.100.123.4")], port=80, hostname="bbx-bbtest123456.local.", type="_bbxsrv._tcp.local.", diff --git a/tests/components/bluetooth/test_active_update_processor.py b/tests/components/bluetooth/test_active_update_processor.py index 83ad809016a253..fba86223a2d3d7 100644 --- a/tests/components/bluetooth/test_active_update_processor.py +++ b/tests/components/bluetooth/test_active_update_processor.py @@ -91,7 +91,7 @@ async def _poll(*args, **kwargs): # The first time, it was passed the data from parsing the advertisement # The second time, it was passed the data from polling assert len(async_handle_update.mock_calls) == 2 - assert async_handle_update.mock_calls[0] == call({"testdata": 0}) + assert async_handle_update.mock_calls[0] == call({"testdata": 0}, False) assert async_handle_update.mock_calls[1] == call({"testdata": 1}) cancel() @@ -148,7 +148,7 @@ async def _poll(*args, **kwargs): inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO_2) await hass.async_block_till_done() - assert async_handle_update.mock_calls[-1] == call({"testdata": None}) + assert async_handle_update.mock_calls[-1] == call({"testdata": None}, True) flag = True @@ -208,7 +208,7 @@ async def _poll(*args, **kwargs): # First poll fails inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO) await hass.async_block_till_done() - assert async_handle_update.mock_calls[-1] == call({"testdata": None}) + assert async_handle_update.mock_calls[-1] == call({"testdata": None}, False) assert ( "aa:bb:cc:dd:ee:ff: Bluetooth error whilst polling: Connection was aborted" @@ -272,7 +272,7 @@ async def _poll(*args, **kwargs): # First poll fails inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO) await hass.async_block_till_done() - assert async_handle_update.mock_calls[-1] == call({"testdata": None}) + assert async_handle_update.mock_calls[-1] == call({"testdata": None}, False) # Second poll works flag = False @@ -433,7 +433,7 @@ async def _poll(*args, **kwargs): # The first time, it was passed the data from parsing the advertisement # The second time, it was passed the data from polling assert len(async_handle_update.mock_calls) == 2 - assert async_handle_update.mock_calls[0] == call({"testdata": 0}) + assert async_handle_update.mock_calls[0] == call({"testdata": 0}, False) assert async_handle_update.mock_calls[1] == call({"testdata": 1}) hass.state = CoreState.stopping diff --git a/tests/components/bluetooth/test_passive_update_processor.py b/tests/components/bluetooth/test_passive_update_processor.py index c96fbfbfc99e24..5baff65f29ab04 100644 --- a/tests/components/bluetooth/test_passive_update_processor.py +++ b/tests/components/bluetooth/test_passive_update_processor.py @@ -858,22 +858,49 @@ def _async_generate_mock_data( mock_add_entities, ) + entity_key_events = [] + + def _async_entity_key_listener(data: PassiveBluetoothDataUpdate | None) -> None: + """Mock entity key listener.""" + entity_key_events.append(data) + + cancel_async_add_entity_key_listener = processor.async_add_entity_key_listener( + _async_entity_key_listener, + PassiveBluetoothEntityKey(key="humidity", device_id="primary"), + ) + inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO) # First call with just the remote sensor entities results in them being added assert len(mock_add_entities.mock_calls) == 1 + # should have triggered the entity key listener since the + # the device is becoming available + assert len(entity_key_events) == 1 + inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO_2) # Second call with just the remote sensor entities does not add them again assert len(mock_add_entities.mock_calls) == 1 + # should not have triggered the entity key listener since there + # there is no update with the entity key + assert len(entity_key_events) == 1 + inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO) # Third call with primary and remote sensor entities adds the primary sensor entities assert len(mock_add_entities.mock_calls) == 2 + # should not have triggered the entity key listener since there + # there is an update with the entity key + assert len(entity_key_events) == 2 + inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO_2) # Forth call with both primary and remote sensor entities does not add them again assert len(mock_add_entities.mock_calls) == 2 + # should not have triggered the entity key listener since there + # there is an update with the entity key + assert len(entity_key_events) == 3 + entities = [ *mock_add_entities.mock_calls[0][1][0], *mock_add_entities.mock_calls[1][1][0], @@ -892,6 +919,7 @@ def _async_generate_mock_data( assert entity_one.entity_key == PassiveBluetoothEntityKey( key="temperature", device_id="remote" ) + cancel_async_add_entity_key_listener() cancel_coordinator() diff --git a/tests/components/bmw_connected_drive/snapshots/test_sensor.ambr b/tests/components/bmw_connected_drive/snapshots/test_sensor.ambr new file mode 100644 index 00000000000000..d64bdb32597491 --- /dev/null +++ b/tests/components/bmw_connected_drive/snapshots/test_sensor.ambr @@ -0,0 +1,396 @@ +# serializer version: 1 +# name: test_entity_state_attrs + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'iX xDrive50 Remaining range total', + 'icon': 'mdi:map-marker-distance', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ix_xdrive50_remaining_range_total', + 'last_changed': , + 'last_updated': , + 'state': '340', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'iX xDrive50 Mileage', + 'icon': 'mdi:speedometer', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ix_xdrive50_mileage', + 'last_changed': , + 'last_updated': , + 'state': '1121', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'timestamp', + 'friendly_name': 'iX xDrive50 Charging end time', + }), + 'context': , + 'entity_id': 'sensor.ix_xdrive50_charging_end_time', + 'last_changed': , + 'last_updated': , + 'state': '2023-06-22T10:40:00+00:00', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'iX xDrive50 Charging status', + 'icon': 'mdi:ev-station', + }), + 'context': , + 'entity_id': 'sensor.ix_xdrive50_charging_status', + 'last_changed': , + 'last_updated': , + 'state': 'CHARGING', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'battery', + 'friendly_name': 'iX xDrive50 Remaining battery percent', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.ix_xdrive50_remaining_battery_percent', + 'last_changed': , + 'last_updated': , + 'state': '70', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'iX xDrive50 Remaining range electric', + 'icon': 'mdi:map-marker-distance', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ix_xdrive50_remaining_range_electric', + 'last_changed': , + 'last_updated': , + 'state': '340', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'iX xDrive50 Charging target', + 'icon': 'mdi:battery-charging-high', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.ix_xdrive50_charging_target', + 'last_changed': , + 'last_updated': , + 'state': '80', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i4 eDrive40 Remaining range total', + 'icon': 'mdi:map-marker-distance', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.i4_edrive40_remaining_range_total', + 'last_changed': , + 'last_updated': , + 'state': '472', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i4 eDrive40 Mileage', + 'icon': 'mdi:speedometer', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.i4_edrive40_mileage', + 'last_changed': , + 'last_updated': , + 'state': '1121', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'timestamp', + 'friendly_name': 'i4 eDrive40 Charging end time', + }), + 'context': , + 'entity_id': 'sensor.i4_edrive40_charging_end_time', + 'last_changed': , + 'last_updated': , + 'state': '2023-06-22T10:40:00+00:00', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i4 eDrive40 Charging status', + 'icon': 'mdi:ev-station', + }), + 'context': , + 'entity_id': 'sensor.i4_edrive40_charging_status', + 'last_changed': , + 'last_updated': , + 'state': 'NOT_CHARGING', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'battery', + 'friendly_name': 'i4 eDrive40 Remaining battery percent', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.i4_edrive40_remaining_battery_percent', + 'last_changed': , + 'last_updated': , + 'state': '80', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i4 eDrive40 Remaining range electric', + 'icon': 'mdi:map-marker-distance', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.i4_edrive40_remaining_range_electric', + 'last_changed': , + 'last_updated': , + 'state': '472', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i4 eDrive40 Charging target', + 'icon': 'mdi:battery-charging-high', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.i4_edrive40_charging_target', + 'last_changed': , + 'last_updated': , + 'state': '80', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'M340i xDrive Remaining range total', + 'icon': 'mdi:map-marker-distance', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.m340i_xdrive_remaining_range_total', + 'last_changed': , + 'last_updated': , + 'state': '629', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'M340i xDrive Mileage', + 'icon': 'mdi:speedometer', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.m340i_xdrive_mileage', + 'last_changed': , + 'last_updated': , + 'state': '1121', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'M340i xDrive Remaining fuel', + 'icon': 'mdi:gas-station', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.m340i_xdrive_remaining_fuel', + 'last_changed': , + 'last_updated': , + 'state': '40', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'M340i xDrive Remaining range fuel', + 'icon': 'mdi:map-marker-distance', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.m340i_xdrive_remaining_range_fuel', + 'last_changed': , + 'last_updated': , + 'state': '629', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'M340i xDrive Remaining fuel percent', + 'icon': 'mdi:gas-station', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.m340i_xdrive_remaining_fuel_percent', + 'last_changed': , + 'last_updated': , + 'state': '80', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i3 (+ REX) Remaining range total', + 'icon': 'mdi:map-marker-distance', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.i3_rex_remaining_range_total', + 'last_changed': , + 'last_updated': , + 'state': '279', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i3 (+ REX) Mileage', + 'icon': 'mdi:speedometer', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.i3_rex_mileage', + 'last_changed': , + 'last_updated': , + 'state': '137009', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'timestamp', + 'friendly_name': 'i3 (+ REX) Charging end time', + }), + 'context': , + 'entity_id': 'sensor.i3_rex_charging_end_time', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i3 (+ REX) Charging status', + 'icon': 'mdi:ev-station', + }), + 'context': , + 'entity_id': 'sensor.i3_rex_charging_status', + 'last_changed': , + 'last_updated': , + 'state': 'WAITING_FOR_CHARGING', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'battery', + 'friendly_name': 'i3 (+ REX) Remaining battery percent', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.i3_rex_remaining_battery_percent', + 'last_changed': , + 'last_updated': , + 'state': '82', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i3 (+ REX) Remaining range electric', + 'icon': 'mdi:map-marker-distance', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.i3_rex_remaining_range_electric', + 'last_changed': , + 'last_updated': , + 'state': '174', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i3 (+ REX) Charging target', + 'icon': 'mdi:battery-charging-high', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.i3_rex_charging_target', + 'last_changed': , + 'last_updated': , + 'state': '100', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i3 (+ REX) Remaining fuel', + 'icon': 'mdi:gas-station', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.i3_rex_remaining_fuel', + 'last_changed': , + 'last_updated': , + 'state': '6', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i3 (+ REX) Remaining range fuel', + 'icon': 'mdi:map-marker-distance', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.i3_rex_remaining_range_fuel', + 'last_changed': , + 'last_updated': , + 'state': '105', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i3 (+ REX) Remaining fuel percent', + 'icon': 'mdi:gas-station', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.i3_rex_remaining_fuel_percent', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + ]) +# --- diff --git a/tests/components/bmw_connected_drive/test_sensor.py b/tests/components/bmw_connected_drive/test_sensor.py index 95b1145d9d6ed8..c6cb12cf047022 100644 --- a/tests/components/bmw_connected_drive/test_sensor.py +++ b/tests/components/bmw_connected_drive/test_sensor.py @@ -1,5 +1,8 @@ """Test BMW sensors.""" +from freezegun import freeze_time import pytest +import respx +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant from homeassistant.util.unit_system import ( @@ -11,6 +14,21 @@ from . import setup_mocked_integration +@freeze_time("2023-06-22 10:30:00+00:00") +async def test_entity_state_attrs( + hass: HomeAssistant, + bmw_fixture: respx.Router, + snapshot: SnapshotAssertion, +) -> None: + """Test sensor options and values..""" + + # Setup component + assert await setup_mocked_integration(hass) + + # Get all select entities + assert hass.states.async_all("sensor") == snapshot + + @pytest.mark.parametrize( ("entity_id", "unit_system", "value", "unit_of_measurement"), [ diff --git a/tests/components/bond/test_config_flow.py b/tests/components/bond/test_config_flow.py index fab579a81a3cb7..91d628e4841e01 100644 --- a/tests/components/bond/test_config_flow.py +++ b/tests/components/bond/test_config_flow.py @@ -3,6 +3,7 @@ import asyncio from http import HTTPStatus +from ipaddress import ip_address from typing import Any from unittest.mock import MagicMock, Mock, patch @@ -203,8 +204,8 @@ async def test_zeroconf_form(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="test-host", - addresses=["test-host"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="ZXXX12345.some-other-tail-info", port=None, @@ -227,7 +228,7 @@ async def test_zeroconf_form(hass: HomeAssistant) -> None: assert result2["type"] == "create_entry" assert result2["title"] == "bond-name" assert result2["data"] == { - CONF_HOST: "test-host", + CONF_HOST: "127.0.0.1", CONF_ACCESS_TOKEN: "test-token", } assert len(mock_setup_entry.mock_calls) == 1 @@ -241,8 +242,8 @@ async def test_zeroconf_form_token_unavailable(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="test-host", - addresses=["test-host"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="ZXXX12345.some-other-tail-info", port=None, @@ -264,7 +265,7 @@ async def test_zeroconf_form_token_unavailable(hass: HomeAssistant) -> None: assert result2["type"] == "create_entry" assert result2["title"] == "bond-name" assert result2["data"] == { - CONF_HOST: "test-host", + CONF_HOST: "127.0.0.1", CONF_ACCESS_TOKEN: "test-token", } assert len(mock_setup_entry.mock_calls) == 1 @@ -278,8 +279,8 @@ async def test_zeroconf_form_token_times_out(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="test-host", - addresses=["test-host"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="ZXXX12345.some-other-tail-info", port=None, @@ -301,7 +302,7 @@ async def test_zeroconf_form_token_times_out(hass: HomeAssistant) -> None: assert result2["type"] == "create_entry" assert result2["title"] == "bond-name" assert result2["data"] == { - CONF_HOST: "test-host", + CONF_HOST: "127.0.0.1", CONF_ACCESS_TOKEN: "test-token", } assert len(mock_setup_entry.mock_calls) == 1 @@ -319,8 +320,8 @@ async def test_zeroconf_form_with_token_available(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="test-host", - addresses=["test-host"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="ZXXX12345.some-other-tail-info", port=None, @@ -342,7 +343,7 @@ async def test_zeroconf_form_with_token_available(hass: HomeAssistant) -> None: assert result2["type"] == "create_entry" assert result2["title"] == "discovered-name" assert result2["data"] == { - CONF_HOST: "test-host", + CONF_HOST: "127.0.0.1", CONF_ACCESS_TOKEN: "discovered-token", } assert len(mock_setup_entry.mock_calls) == 1 @@ -360,8 +361,8 @@ async def test_zeroconf_form_with_token_available_name_unavailable( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="test-host", - addresses=["test-host"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="ZXXX12345.some-other-tail-info", port=None, @@ -383,7 +384,7 @@ async def test_zeroconf_form_with_token_available_name_unavailable( assert result2["type"] == "create_entry" assert result2["title"] == "ZXXX12345" assert result2["data"] == { - CONF_HOST: "test-host", + CONF_HOST: "127.0.0.1", CONF_ACCESS_TOKEN: "discovered-token", } assert len(mock_setup_entry.mock_calls) == 1 @@ -404,8 +405,8 @@ async def test_zeroconf_already_configured(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="updated-host", - addresses=["updated-host"], + ip_address=ip_address("127.0.0.2"), + ip_addresses=[ip_address("127.0.0.2")], hostname="mock_hostname", name="already-registered-bond-id.some-other-tail-info", port=None, @@ -417,7 +418,7 @@ async def test_zeroconf_already_configured(hass: HomeAssistant) -> None: assert result["type"] == "abort" assert result["reason"] == "already_configured" - assert entry.data["host"] == "updated-host" + assert entry.data["host"] == "127.0.0.2" assert len(mock_setup_entry.mock_calls) == 1 @@ -442,8 +443,8 @@ async def test_zeroconf_in_setup_retry_state(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="updated-host", - addresses=["updated-host"], + ip_address=ip_address("127.0.0.2"), + ip_addresses=[ip_address("127.0.0.2")], hostname="mock_hostname", name="already-registered-bond-id.some-other-tail-info", port=None, @@ -455,7 +456,7 @@ async def test_zeroconf_in_setup_retry_state(hass: HomeAssistant) -> None: assert result["type"] == "abort" assert result["reason"] == "already_configured" - assert entry.data["host"] == "updated-host" + assert entry.data["host"] == "127.0.0.2" assert len(mock_setup_entry.mock_calls) == 1 assert entry.state is ConfigEntryState.LOADED @@ -488,8 +489,8 @@ async def test_zeroconf_already_configured_refresh_token(hass: HomeAssistant) -> DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="updated-host", - addresses=["updated-host"], + ip_address=ip_address("127.0.0.2"), + ip_addresses=[ip_address("127.0.0.2")], hostname="mock_hostname", name="already-registered-bond-id.some-other-tail-info", port=None, @@ -501,7 +502,7 @@ async def test_zeroconf_already_configured_refresh_token(hass: HomeAssistant) -> assert result["type"] == "abort" assert result["reason"] == "already_configured" - assert entry.data["host"] == "updated-host" + assert entry.data["host"] == "127.0.0.2" assert entry.data[CONF_ACCESS_TOKEN] == "discovered-token" # entry2 should not get changed assert entry2.data[CONF_ACCESS_TOKEN] == "correct-token" @@ -515,7 +516,7 @@ async def test_zeroconf_already_configured_no_reload_same_host( entry = MockConfigEntry( domain=DOMAIN, unique_id="already-registered-bond-id", - data={CONF_HOST: "stored-host", CONF_ACCESS_TOKEN: "correct-token"}, + data={CONF_HOST: "127.0.0.3", CONF_ACCESS_TOKEN: "correct-token"}, ) entry.add_to_hass(hass) @@ -526,8 +527,8 @@ async def test_zeroconf_already_configured_no_reload_same_host( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="stored-host", - addresses=["stored-host"], + ip_address=ip_address("127.0.0.3"), + ip_addresses=[ip_address("127.0.0.3")], hostname="mock_hostname", name="already-registered-bond-id.some-other-tail-info", port=None, @@ -548,8 +549,8 @@ async def test_zeroconf_form_unexpected_error(hass: HomeAssistant) -> None: hass, source=config_entries.SOURCE_ZEROCONF, initial_input=zeroconf.ZeroconfServiceInfo( - host="test-host", - addresses=["test-host"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="ZXXX12345.some-other-tail-info", port=None, diff --git a/tests/components/bosch_shc/test_config_flow.py b/tests/components/bosch_shc/test_config_flow.py index 92f49b86ef706b..e5d0abb3c9defb 100644 --- a/tests/components/bosch_shc/test_config_flow.py +++ b/tests/components/bosch_shc/test_config_flow.py @@ -1,4 +1,5 @@ """Test the Bosch SHC config flow.""" +from ipaddress import ip_address from unittest.mock import PropertyMock, mock_open, patch from boschshcpy.exceptions import ( @@ -22,8 +23,8 @@ "device": {"mac": "test-mac", "hostname": "test-host"}, } DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( - host="1.1.1.1", - addresses=["1.1.1.1"], + ip_address=ip_address("1.1.1.1"), + ip_addresses=[ip_address("1.1.1.1")], hostname="shc012345.local.", name="Bosch SHC [test-mac]._http._tcp.local.", port=0, @@ -548,8 +549,8 @@ async def test_zeroconf_not_bosch_shc(hass: HomeAssistant, mock_zeroconf: None) result = await hass.config_entries.flow.async_init( DOMAIN, data=zeroconf.ZeroconfServiceInfo( - host="1.1.1.1", - addresses=["1.1.1.1"], + ip_address=ip_address("1.1.1.1"), + ip_addresses=[ip_address("1.1.1.1")], hostname="mock_hostname", name="notboschshc", port=None, diff --git a/tests/components/brother/test_config_flow.py b/tests/components/brother/test_config_flow.py index 629295e09e0260..f83f882b8a0205 100644 --- a/tests/components/brother/test_config_flow.py +++ b/tests/components/brother/test_config_flow.py @@ -1,4 +1,5 @@ """Define tests for the Brother Printer config flow.""" +from ipaddress import ip_address import json from unittest.mock import patch @@ -155,8 +156,8 @@ async def test_zeroconf_snmp_error(hass: HomeAssistant) -> None: DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["mock_host"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="example.local.", name="Brother Printer", port=None, @@ -178,8 +179,8 @@ async def test_zeroconf_unsupported_model(hass: HomeAssistant) -> None: DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["mock_host"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="example.local.", name="Brother Printer", port=None, @@ -210,8 +211,8 @@ async def test_zeroconf_device_exists_abort(hass: HomeAssistant) -> None: DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["mock_host"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="example.local.", name="Brother Printer", port=None, @@ -238,8 +239,8 @@ async def test_zeroconf_no_probe_existing_device(hass: HomeAssistant) -> None: DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["mock_host"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="example.local.", name="Brother Printer", port=None, @@ -264,8 +265,8 @@ async def test_zeroconf_confirm_create_entry(hass: HomeAssistant) -> None: DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["mock_host"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="example.local.", name="Brother Printer", port=None, diff --git a/tests/components/cert_expiry/test_config_flow.py b/tests/components/cert_expiry/test_config_flow.py index 52985da00146c5..f950fce6a68a9b 100644 --- a/tests/components/cert_expiry/test_config_flow.py +++ b/tests/components/cert_expiry/test_config_flow.py @@ -67,7 +67,7 @@ async def test_import_host_only(hass: HomeAssistant) -> None: with patch( "homeassistant.components.cert_expiry.config_flow.get_cert_expiry_timestamp" ), patch( - "homeassistant.components.cert_expiry.get_cert_expiry_timestamp", + "homeassistant.components.cert_expiry.coordinator.get_cert_expiry_timestamp", return_value=future_timestamp(1), ): result = await hass.config_entries.flow.async_init( @@ -89,7 +89,7 @@ async def test_import_host_and_port(hass: HomeAssistant) -> None: with patch( "homeassistant.components.cert_expiry.config_flow.get_cert_expiry_timestamp" ), patch( - "homeassistant.components.cert_expiry.get_cert_expiry_timestamp", + "homeassistant.components.cert_expiry.coordinator.get_cert_expiry_timestamp", return_value=future_timestamp(1), ): result = await hass.config_entries.flow.async_init( @@ -111,7 +111,7 @@ async def test_import_non_default_port(hass: HomeAssistant) -> None: with patch( "homeassistant.components.cert_expiry.config_flow.get_cert_expiry_timestamp" ), patch( - "homeassistant.components.cert_expiry.get_cert_expiry_timestamp", + "homeassistant.components.cert_expiry.coordinator.get_cert_expiry_timestamp", return_value=future_timestamp(1), ): result = await hass.config_entries.flow.async_init( @@ -133,7 +133,7 @@ async def test_import_with_name(hass: HomeAssistant) -> None: with patch( "homeassistant.components.cert_expiry.config_flow.get_cert_expiry_timestamp" ), patch( - "homeassistant.components.cert_expiry.get_cert_expiry_timestamp", + "homeassistant.components.cert_expiry.coordinator.get_cert_expiry_timestamp", return_value=future_timestamp(1), ): result = await hass.config_entries.flow.async_init( diff --git a/tests/components/cert_expiry/test_init.py b/tests/components/cert_expiry/test_init.py index 29fbf372ec42cc..6c1d593560e8c2 100644 --- a/tests/components/cert_expiry/test_init.py +++ b/tests/components/cert_expiry/test_init.py @@ -42,7 +42,7 @@ async def test_setup_with_config(hass: HomeAssistant) -> None: with patch( "homeassistant.components.cert_expiry.config_flow.get_cert_expiry_timestamp" ), patch( - "homeassistant.components.cert_expiry.get_cert_expiry_timestamp", + "homeassistant.components.cert_expiry.coordinator.get_cert_expiry_timestamp", return_value=future_timestamp(1), ): await hass.async_block_till_done() @@ -63,7 +63,7 @@ async def test_update_unique_id(hass: HomeAssistant) -> None: assert not entry.unique_id with patch( - "homeassistant.components.cert_expiry.get_cert_expiry_timestamp", + "homeassistant.components.cert_expiry.coordinator.get_cert_expiry_timestamp", return_value=future_timestamp(1), ): assert await async_setup_component(hass, DOMAIN, {}) is True @@ -91,7 +91,7 @@ async def test_unload_config_entry(mock_now, hass: HomeAssistant) -> None: timestamp = future_timestamp(100) with patch( - "homeassistant.components.cert_expiry.get_cert_expiry_timestamp", + "homeassistant.components.cert_expiry.coordinator.get_cert_expiry_timestamp", return_value=timestamp, ): assert await async_setup_component(hass, DOMAIN, {}) is True @@ -134,7 +134,7 @@ async def test_delay_load_during_startup(hass: HomeAssistant) -> None: timestamp = future_timestamp(100) with patch( - "homeassistant.components.cert_expiry.get_cert_expiry_timestamp", + "homeassistant.components.cert_expiry.coordinator.get_cert_expiry_timestamp", return_value=timestamp, ): await hass.async_start() diff --git a/tests/components/cert_expiry/test_sensors.py b/tests/components/cert_expiry/test_sensors.py index e6a526c7c9e06d..48421f5c41f0ff 100644 --- a/tests/components/cert_expiry/test_sensors.py +++ b/tests/components/cert_expiry/test_sensors.py @@ -29,7 +29,7 @@ async def test_async_setup_entry(mock_now, hass: HomeAssistant) -> None: timestamp = future_timestamp(100) with patch( - "homeassistant.components.cert_expiry.get_cert_expiry_timestamp", + "homeassistant.components.cert_expiry.coordinator.get_cert_expiry_timestamp", return_value=timestamp, ): entry.add_to_hass(hass) @@ -83,7 +83,7 @@ async def test_update_sensor(hass: HomeAssistant) -> None: timestamp = future_timestamp(100) with patch("homeassistant.util.dt.utcnow", return_value=starting_time), patch( - "homeassistant.components.cert_expiry.get_cert_expiry_timestamp", + "homeassistant.components.cert_expiry.coordinator.get_cert_expiry_timestamp", return_value=timestamp, ): entry.add_to_hass(hass) @@ -99,7 +99,7 @@ async def test_update_sensor(hass: HomeAssistant) -> None: next_update = starting_time + timedelta(hours=24) with patch("homeassistant.util.dt.utcnow", return_value=next_update), patch( - "homeassistant.components.cert_expiry.get_cert_expiry_timestamp", + "homeassistant.components.cert_expiry.coordinator.get_cert_expiry_timestamp", return_value=timestamp, ): async_fire_time_changed(hass, utcnow() + timedelta(hours=24)) @@ -127,7 +127,7 @@ async def test_update_sensor_network_errors(hass: HomeAssistant) -> None: timestamp = future_timestamp(100) with patch("homeassistant.util.dt.utcnow", return_value=starting_time), patch( - "homeassistant.components.cert_expiry.get_cert_expiry_timestamp", + "homeassistant.components.cert_expiry.coordinator.get_cert_expiry_timestamp", return_value=timestamp, ): entry.add_to_hass(hass) @@ -156,7 +156,7 @@ async def test_update_sensor_network_errors(hass: HomeAssistant) -> None: assert state.state == STATE_UNAVAILABLE with patch("homeassistant.util.dt.utcnow", return_value=next_update), patch( - "homeassistant.components.cert_expiry.get_cert_expiry_timestamp", + "homeassistant.components.cert_expiry.coordinator.get_cert_expiry_timestamp", return_value=timestamp, ): async_fire_time_changed(hass, utcnow() + timedelta(hours=48)) diff --git a/tests/components/cloud/test_init.py b/tests/components/cloud/test_init.py index 28b531b608c5c0..e12775d5a4a5f4 100644 --- a/tests/components/cloud/test_init.py +++ b/tests/components/cloud/test_init.py @@ -32,7 +32,6 @@ async def test_constructor_loads_info_from_config(hass: HomeAssistant) -> None: "relayer_server": "test-relayer-server", "accounts_server": "test-acounts-server", "cloudhook_server": "test-cloudhook-server", - "remote_sni_server": "test-remote-sni-server", "alexa_server": "test-alexa-server", "acme_server": "test-acme-server", "remotestate_server": "test-remotestate-server", diff --git a/tests/components/daikin/test_config_flow.py b/tests/components/daikin/test_config_flow.py index 27c3b7d9ea35a9..4d54d7483df49a 100644 --- a/tests/components/daikin/test_config_flow.py +++ b/tests/components/daikin/test_config_flow.py @@ -1,5 +1,6 @@ """Tests for the Daikin config flow.""" import asyncio +from ipaddress import ip_address from unittest.mock import PropertyMock, patch from aiohttp import ClientError, web_exceptions @@ -119,8 +120,8 @@ async def test_api_password_abort(hass: HomeAssistant) -> None: ( SOURCE_ZEROCONF, zeroconf.ZeroconfServiceInfo( - host=HOST, - addresses=[HOST], + ip_address=ip_address(HOST), + ip_addresses=[ip_address(HOST)], hostname="mock_hostname", name="mock_name", port=None, diff --git a/tests/components/devolo_home_control/const.py b/tests/components/devolo_home_control/const.py index 96090195d20b77..3351e42c98836f 100644 --- a/tests/components/devolo_home_control/const.py +++ b/tests/components/devolo_home_control/const.py @@ -1,10 +1,12 @@ """Constants used for mocking data.""" +from ipaddress import ip_address + from homeassistant.components import zeroconf DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( - host="192.168.0.1", - addresses=["192.168.0.1"], + ip_address=ip_address("192.168.0.1"), + ip_addresses=[ip_address("192.168.0.1")], port=14791, hostname="test.local.", type="_dvl-deviceapi._tcp.local.", @@ -21,8 +23,8 @@ ) DISCOVERY_INFO_WRONG_DEVOLO_DEVICE = zeroconf.ZeroconfServiceInfo( - host="mock_host", - addresses=["mock_host"], + ip_address=ip_address("192.168.0.1"), + ip_addresses=[ip_address("192.168.0.1")], hostname="mock_hostname", name="mock_name", port=None, @@ -31,8 +33,8 @@ ) DISCOVERY_INFO_WRONG_DEVICE = zeroconf.ZeroconfServiceInfo( - host="mock_host", - addresses=["mock_host"], + ip_address=ip_address("192.168.0.1"), + ip_addresses=[ip_address("192.168.0.1")], hostname="mock_hostname", name="mock_name", port=None, diff --git a/tests/components/devolo_home_network/const.py b/tests/components/devolo_home_network/const.py index bc2ef2d87b20b9..8cf63cf07aea52 100644 --- a/tests/components/devolo_home_network/const.py +++ b/tests/components/devolo_home_network/const.py @@ -1,5 +1,7 @@ """Constants used for mocking data.""" +from ipaddress import ip_address + from devolo_plc_api.device_api import ( UPDATE_AVAILABLE, WIFI_BAND_2G, @@ -30,8 +32,8 @@ NO_CONNECTED_STATIONS = [] DISCOVERY_INFO = ZeroconfServiceInfo( - host=IP, - addresses=[IP], + ip_address=ip_address(IP), + ip_addresses=[ip_address(IP)], port=14791, hostname="test.local.", type="_dvl-deviceapi._tcp.local.", @@ -51,8 +53,8 @@ ) DISCOVERY_INFO_CHANGED = ZeroconfServiceInfo( - host=IP_ALT, - addresses=[IP_ALT], + ip_address=ip_address(IP_ALT), + ip_addresses=[ip_address(IP_ALT)], port=14791, hostname="test.local.", type="_dvl-deviceapi._tcp.local.", @@ -72,8 +74,8 @@ ) DISCOVERY_INFO_WRONG_DEVICE = ZeroconfServiceInfo( - host="mock_host", - addresses=["mock_host"], + ip_address=ip_address("127.0.0.2"), + ip_addresses=[ip_address("127.0.0.2")], hostname="mock_hostname", name="mock_name", port=None, diff --git a/tests/components/discovergy/test_config_flow.py b/tests/components/discovergy/test_config_flow.py index bc4fd2d9e9df06..08e9df06978fc1 100644 --- a/tests/components/discovergy/test_config_flow.py +++ b/tests/components/discovergy/test_config_flow.py @@ -1,7 +1,8 @@ """Test the Discovergy config flow.""" from unittest.mock import Mock, patch -from pydiscovergy.error import HTTPError, InvalidLogin +from pydiscovergy.error import DiscovergyClientError, HTTPError, InvalidLogin +import pytest from homeassistant import data_entry_flow from homeassistant.components.discovergy.const import DOMAIN @@ -10,6 +11,7 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry +from tests.components.discovergy.const import GET_METERS async def test_form(hass: HomeAssistant, mock_meters: Mock) -> None: @@ -73,55 +75,37 @@ async def test_reauth( 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( - DOMAIN, context={"source": SOURCE_USER} - ) +@pytest.mark.parametrize( + ("error", "message"), + [ + (InvalidLogin, "invalid_auth"), + (HTTPError, "cannot_connect"), + (DiscovergyClientError, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_form_fail(hass: HomeAssistant, error: Exception, message: str) -> None: + """Test to handle exceptions.""" with patch( "pydiscovergy.Discovergy.meters", - side_effect=InvalidLogin, + side_effect=error, ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_EMAIL: "test@example.com", - CONF_PASSWORD: "test-password", - }, - ) - - assert result2["type"] == data_entry_flow.FlowResultType.FORM - assert result2["errors"] == {"base": "invalid_auth"} - - -async def test_form_cannot_connect(hass: HomeAssistant) -> None: - """Test we handle cannot connect error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - - with patch("pydiscovergy.Discovergy.meters", side_effect=HTTPError): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ CONF_EMAIL: "test@example.com", CONF_PASSWORD: "test-password", }, ) - assert result2["type"] == data_entry_flow.FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": message} - -async def test_form_unknown_exception(hass: HomeAssistant) -> None: - """Test we handle cannot connect error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - - with patch("pydiscovergy.Discovergy.meters", side_effect=Exception): - result2 = await hass.config_entries.flow.async_configure( + with patch("pydiscovergy.Discovergy.meters", return_value=GET_METERS): + result = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_EMAIL: "test@example.com", @@ -129,5 +113,6 @@ async def test_form_unknown_exception(hass: HomeAssistant) -> None: }, ) - assert result2["type"] == data_entry_flow.FlowResultType.FORM - assert result2["errors"] == {"base": "unknown"} + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["title"] == "test@example.com" + assert "errors" not in result diff --git a/tests/components/doorbird/test_config_flow.py b/tests/components/doorbird/test_config_flow.py index e982f4ca172067..7ad7fbe07ace42 100644 --- a/tests/components/doorbird/test_config_flow.py +++ b/tests/components/doorbird/test_config_flow.py @@ -1,4 +1,5 @@ """Test the DoorBird config flow.""" +from ipaddress import ip_address from unittest.mock import MagicMock, Mock, patch import pytest @@ -84,8 +85,8 @@ async def test_form_zeroconf_wrong_oui(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.8", - addresses=["192.168.1.8"], + ip_address=ip_address("192.168.1.8"), + ip_addresses=[ip_address("192.168.1.8")], hostname="mock_hostname", name="Doorstation - abc123._axis-video._tcp.local.", port=None, @@ -104,8 +105,8 @@ async def test_form_zeroconf_link_local_ignored(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="169.254.103.61", - addresses=["169.254.103.61"], + ip_address=ip_address("169.254.103.61"), + ip_addresses=[ip_address("169.254.103.61")], hostname="mock_hostname", name="Doorstation - abc123._axis-video._tcp.local.", port=None, @@ -131,8 +132,8 @@ async def test_form_zeroconf_ipv4_address(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="4.4.4.4", - addresses=["4.4.4.4"], + ip_address=ip_address("4.4.4.4"), + ip_addresses=[ip_address("4.4.4.4")], hostname="mock_hostname", name="Doorstation - abc123._axis-video._tcp.local.", port=None, @@ -152,8 +153,8 @@ async def test_form_zeroconf_non_ipv4_ignored(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="fd00::b27c:63bb:cc85:4ea0", - addresses=["fd00::b27c:63bb:cc85:4ea0"], + ip_address=ip_address("fd00::b27c:63bb:cc85:4ea0"), + ip_addresses=[ip_address("fd00::b27c:63bb:cc85:4ea0")], hostname="mock_hostname", name="Doorstation - abc123._axis-video._tcp.local.", port=None, @@ -179,8 +180,8 @@ async def test_form_zeroconf_correct_oui(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.5", - addresses=["192.168.1.5"], + ip_address=ip_address("192.168.1.5"), + ip_addresses=[ip_address("192.168.1.5")], hostname="mock_hostname", name="Doorstation - abc123._axis-video._tcp.local.", port=None, @@ -244,8 +245,8 @@ async def test_form_zeroconf_correct_oui_wrong_device( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.5", - addresses=["192.168.1.5"], + ip_address=ip_address("192.168.1.5"), + ip_addresses=[ip_address("192.168.1.5")], hostname="mock_hostname", name="Doorstation - abc123._axis-video._tcp.local.", port=None, diff --git a/tests/components/ecoforest/__init__.py b/tests/components/ecoforest/__init__.py new file mode 100644 index 00000000000000..031cba659d2d50 --- /dev/null +++ b/tests/components/ecoforest/__init__.py @@ -0,0 +1 @@ +"""Tests for the Ecoforest integration.""" diff --git a/tests/components/ecoforest/conftest.py b/tests/components/ecoforest/conftest.py new file mode 100644 index 00000000000000..09860546c1552c --- /dev/null +++ b/tests/components/ecoforest/conftest.py @@ -0,0 +1,73 @@ +"""Common fixtures for the Ecoforest tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, Mock, patch + +from pyecoforest.models.device import Alarm, Device, OperationMode, State +import pytest + +from homeassistant.components.ecoforest import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.ecoforest.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture(name="config") +def config_fixture(): + """Define a config entry data fixture.""" + return { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + } + + +@pytest.fixture(name="serial_number") +def serial_number_fixture(): + """Define a serial number fixture.""" + return "1234" + + +@pytest.fixture(name="mock_device") +def mock_device_fixture(serial_number): + """Define a mocked Ecoforest device fixture.""" + mock = Mock(spec=Device) + mock.model = "model-version" + mock.model_name = "model-name" + mock.firmware = "firmware-version" + mock.serial_number = serial_number + mock.operation_mode = OperationMode.POWER + mock.on = False + mock.state = State.OFF + mock.power = 3 + mock.temperature = 21.5 + mock.alarm = Alarm.PELLETS + mock.alarm_code = "A099" + mock.environment_temperature = 23.5 + mock.cpu_temperature = 36.1 + mock.gas_temperature = 40.2 + mock.ntc_temperature = 24.2 + return mock + + +@pytest.fixture(name="config_entry") +def config_entry_fixture(hass: HomeAssistant, config, serial_number): + """Define a config entry fixture.""" + entry = MockConfigEntry( + domain=DOMAIN, + entry_id="45a36e55aaddb2007c5f6602e0c38e72", + title=f"Ecoforest {serial_number}", + unique_id=serial_number, + data=config, + ) + entry.add_to_hass(hass) + return entry diff --git a/tests/components/ecoforest/test_config_flow.py b/tests/components/ecoforest/test_config_flow.py new file mode 100644 index 00000000000000..302cbe76fa93ff --- /dev/null +++ b/tests/components/ecoforest/test_config_flow.py @@ -0,0 +1,115 @@ +"""Test the Ecoforest config flow.""" +from unittest.mock import AsyncMock, patch + +from pyecoforest.exceptions import EcoforestAuthenticationRequired +import pytest + +from homeassistant import config_entries +from homeassistant.components.ecoforest.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +async def test_form( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_device, config +) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {} + + with patch( + "pyecoforest.api.EcoforestApi.get", + return_value=mock_device, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + config, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert "result" in result + assert result["result"].unique_id == "1234" + assert result["title"] == "Ecoforest 1234" + assert result["data"] == { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_device_already_configured( + hass: HomeAssistant, mock_setup_entry: AsyncMock, config_entry, mock_device, config +) -> None: + """Test device already exists.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {} + + with patch( + "pyecoforest.api.EcoforestApi.get", + return_value=mock_device, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + config, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("error", "message"), + [ + ( + EcoforestAuthenticationRequired("401"), + "invalid_auth", + ), + ( + Exception("Something wrong"), + "cannot_connect", + ), + ], +) +async def test_flow_fails( + hass: HomeAssistant, error: Exception, message: str, mock_device, config +) -> None: + """Test we handle failed flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "pyecoforest.api.EcoforestApi.get", + side_effect=error, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + config, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": message} + + with patch( + "pyecoforest.api.EcoforestApi.get", + return_value=mock_device, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + config, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY diff --git a/tests/components/electric_kiwi/conftest.py b/tests/components/electric_kiwi/conftest.py index 525f5742382973..f7e60e975f8fae 100644 --- a/tests/components/electric_kiwi/conftest.py +++ b/tests/components/electric_kiwi/conftest.py @@ -1,9 +1,12 @@ """Define fixtures for electric kiwi tests.""" from __future__ import annotations -from collections.abc import Generator +from collections.abc import Awaitable, Callable, Generator +from time import time from unittest.mock import AsyncMock, patch +import zoneinfo +from electrickiwi_api.model import Hop, HopIntervals import pytest from homeassistant.components.application_credentials import ( @@ -14,12 +17,17 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, load_json_value_fixture CLIENT_ID = "1234" CLIENT_SECRET = "5678" REDIRECT_URI = "https://example.com/auth/external/callback" +TZ_NAME = "Pacific/Auckland" +TIMEZONE = zoneinfo.ZoneInfo(TZ_NAME) +YieldFixture = Generator[AsyncMock, None, None] +ComponentSetup = Callable[[], Awaitable[bool]] + @pytest.fixture(autouse=True) async def request_setup(current_request_with_host) -> None: @@ -28,14 +36,23 @@ async def request_setup(current_request_with_host) -> None: @pytest.fixture -async def setup_credentials(hass: HomeAssistant) -> None: - """Fixture to setup credentials.""" - assert await async_setup_component(hass, "application_credentials", {}) - await async_import_client_credential( - hass, - DOMAIN, - ClientCredential(CLIENT_ID, CLIENT_SECRET), - ) +def component_setup( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> ComponentSetup: + """Fixture for setting up the integration.""" + + async def _setup_func() -> bool: + assert await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), + DOMAIN, + ) + config_entry.add_to_hass(hass) + return await hass.config_entries.async_setup(config_entry.entry_id) + + return _setup_func @pytest.fixture(name="config_entry") @@ -45,12 +62,18 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: title="Electric Kiwi", domain=DOMAIN, data={ - "id": "mock_user", + "id": "12345", "auth_implementation": DOMAIN, + "token": { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + "expires_at": time() + 60, + }, }, unique_id=DOMAIN, ) - entry.add_to_hass(hass) return entry @@ -61,3 +84,33 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: "homeassistant.components.electric_kiwi.async_setup_entry", return_value=True ) as mock_setup: yield mock_setup + + +@pytest.fixture(name="ek_auth") +def electric_kiwi_auth() -> YieldFixture: + """Patch access to electric kiwi access token.""" + with patch( + "homeassistant.components.electric_kiwi.api.AsyncConfigEntryAuth" + ) as mock_auth: + mock_auth.return_value.async_get_access_token = AsyncMock("auth_token") + yield mock_auth + + +@pytest.fixture(name="ek_api") +def ek_api() -> YieldFixture: + """Mock ek api and return values.""" + with patch( + "homeassistant.components.electric_kiwi.ElectricKiwiApi", autospec=True + ) as mock_ek_api: + mock_ek_api.return_value.customer_number = 123456 + mock_ek_api.return_value.connection_id = 123456 + mock_ek_api.return_value.set_active_session.return_value = None + mock_ek_api.return_value.get_hop_intervals.return_value = ( + HopIntervals.from_dict( + load_json_value_fixture("hop_intervals.json", DOMAIN) + ) + ) + mock_ek_api.return_value.get_hop.return_value = Hop.from_dict( + load_json_value_fixture("get_hop.json", DOMAIN) + ) + yield mock_ek_api diff --git a/tests/components/electric_kiwi/fixtures/get_hop.json b/tests/components/electric_kiwi/fixtures/get_hop.json new file mode 100644 index 00000000000000..d29825391e906d --- /dev/null +++ b/tests/components/electric_kiwi/fixtures/get_hop.json @@ -0,0 +1,16 @@ +{ + "data": { + "connection_id": "3", + "customer_number": 1000001, + "end": { + "end_time": "5:00 PM", + "interval": "34" + }, + "start": { + "start_time": "4:00 PM", + "interval": "33" + }, + "type": "hop_customer" + }, + "status": 1 +} diff --git a/tests/components/electric_kiwi/fixtures/hop_intervals.json b/tests/components/electric_kiwi/fixtures/hop_intervals.json new file mode 100644 index 00000000000000..15ecc174f13208 --- /dev/null +++ b/tests/components/electric_kiwi/fixtures/hop_intervals.json @@ -0,0 +1,249 @@ +{ + "data": { + "hop_duration": "60", + "type": "hop_intervals", + "intervals": { + "1": { + "active": 1, + "end_time": "1:00 AM", + "start_time": "12:00 AM" + }, + "2": { + "active": 1, + "end_time": "1:30 AM", + "start_time": "12:30 AM" + }, + "3": { + "active": 1, + "end_time": "2:00 AM", + "start_time": "1:00 AM" + }, + "4": { + "active": 1, + "end_time": "2:30 AM", + "start_time": "1:30 AM" + }, + "5": { + "active": 1, + "end_time": "3:00 AM", + "start_time": "2:00 AM" + }, + "6": { + "active": 1, + "end_time": "3:30 AM", + "start_time": "2:30 AM" + }, + "7": { + "active": 1, + "end_time": "4:00 AM", + "start_time": "3:00 AM" + }, + "8": { + "active": 1, + "end_time": "4:30 AM", + "start_time": "3:30 AM" + }, + "9": { + "active": 1, + "end_time": "5:00 AM", + "start_time": "4:00 AM" + }, + "10": { + "active": 1, + "end_time": "5:30 AM", + "start_time": "4:30 AM" + }, + "11": { + "active": 1, + "end_time": "6:00 AM", + "start_time": "5:00 AM" + }, + "12": { + "active": 1, + "end_time": "6:30 AM", + "start_time": "5:30 AM" + }, + "13": { + "active": 1, + "end_time": "7:00 AM", + "start_time": "6:00 AM" + }, + "14": { + "active": 1, + "end_time": "7:30 AM", + "start_time": "6:30 AM" + }, + "15": { + "active": 1, + "end_time": "8:00 AM", + "start_time": "7:00 AM" + }, + "16": { + "active": 1, + "end_time": "8:30 AM", + "start_time": "7:30 AM" + }, + "17": { + "active": 1, + "end_time": "9:00 AM", + "start_time": "8:00 AM" + }, + "18": { + "active": 1, + "end_time": "9:30 AM", + "start_time": "8:30 AM" + }, + "19": { + "active": 1, + "end_time": "10:00 AM", + "start_time": "9:00 AM" + }, + "20": { + "active": 1, + "end_time": "10:30 AM", + "start_time": "9:30 AM" + }, + "21": { + "active": 1, + "end_time": "11:00 AM", + "start_time": "10:00 AM" + }, + "22": { + "active": 1, + "end_time": "11:30 AM", + "start_time": "10:30 AM" + }, + "23": { + "active": 1, + "end_time": "12:00 PM", + "start_time": "11:00 AM" + }, + "24": { + "active": 1, + "end_time": "12:30 PM", + "start_time": "11:30 AM" + }, + "25": { + "active": 1, + "end_time": "1:00 PM", + "start_time": "12:00 PM" + }, + "26": { + "active": 1, + "end_time": "1:30 PM", + "start_time": "12:30 PM" + }, + "27": { + "active": 1, + "end_time": "2:00 PM", + "start_time": "1:00 PM" + }, + "28": { + "active": 1, + "end_time": "2:30 PM", + "start_time": "1:30 PM" + }, + "29": { + "active": 1, + "end_time": "3:00 PM", + "start_time": "2:00 PM" + }, + "30": { + "active": 1, + "end_time": "3:30 PM", + "start_time": "2:30 PM" + }, + "31": { + "active": 1, + "end_time": "4:00 PM", + "start_time": "3:00 PM" + }, + "32": { + "active": 1, + "end_time": "4:30 PM", + "start_time": "3:30 PM" + }, + "33": { + "active": 1, + "end_time": "5:00 PM", + "start_time": "4:00 PM" + }, + "34": { + "active": 1, + "end_time": "5:30 PM", + "start_time": "4:30 PM" + }, + "35": { + "active": 1, + "end_time": "6:00 PM", + "start_time": "5:00 PM" + }, + "36": { + "active": 1, + "end_time": "6:30 PM", + "start_time": "5:30 PM" + }, + "37": { + "active": 1, + "end_time": "7:00 PM", + "start_time": "6:00 PM" + }, + "38": { + "active": 1, + "end_time": "7:30 PM", + "start_time": "6:30 PM" + }, + "39": { + "active": 1, + "end_time": "8:00 PM", + "start_time": "7:00 PM" + }, + "40": { + "active": 1, + "end_time": "8:30 PM", + "start_time": "7:30 PM" + }, + "41": { + "active": 1, + "end_time": "9:00 PM", + "start_time": "8:00 PM" + }, + "42": { + "active": 1, + "end_time": "9:30 PM", + "start_time": "8:30 PM" + }, + "43": { + "active": 1, + "end_time": "10:00 PM", + "start_time": "9:00 PM" + }, + "44": { + "active": 1, + "end_time": "10:30 PM", + "start_time": "9:30 PM" + }, + "45": { + "active": 1, + "end_time": "11:00 AM", + "start_time": "10:00 PM" + }, + "46": { + "active": 1, + "end_time": "11:30 PM", + "start_time": "10:30 PM" + }, + "47": { + "active": 1, + "end_time": "12:00 AM", + "start_time": "11:00 PM" + }, + "48": { + "active": 1, + "end_time": "12:30 AM", + "start_time": "11:30 PM" + } + } + }, + "status": 1 +} diff --git a/tests/components/electric_kiwi/test_config_flow.py b/tests/components/electric_kiwi/test_config_flow.py index 51d00722341f53..1199c3e555a537 100644 --- a/tests/components/electric_kiwi/test_config_flow.py +++ b/tests/components/electric_kiwi/test_config_flow.py @@ -21,6 +21,7 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.setup import async_setup_component from .conftest import CLIENT_ID, CLIENT_SECRET, REDIRECT_URI @@ -31,6 +32,17 @@ pytestmark = pytest.mark.usefixtures("mock_setup_entry") +@pytest.fixture +async def setup_credentials(hass: HomeAssistant) -> None: + """Fixture to setup application credentials component.""" + await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), + ) + + async def test_config_flow_no_credentials(hass: HomeAssistant) -> None: """Test config flow base case with no credentials registered.""" result = await hass.config_entries.flow.async_init( @@ -45,12 +57,12 @@ async def test_full_flow( hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, current_request_with_host: None, - setup_credentials, + setup_credentials: None, mock_setup_entry: AsyncMock, ) -> None: """Check full flow.""" await async_import_client_credential( - hass, DOMAIN, ClientCredential(CLIENT_ID, CLIENT_SECRET), "imported-cred" + hass, DOMAIN, ClientCredential(CLIENT_ID, CLIENT_SECRET) ) result = await hass.config_entries.flow.async_init( @@ -103,7 +115,7 @@ async def test_existing_entry( config_entry: MockConfigEntry, ) -> None: """Check existing entry.""" - + config_entry.add_to_hass(hass) assert len(hass.config_entries.async_entries(DOMAIN)) == 1 result = await hass.config_entries.flow.async_init( diff --git a/tests/components/electric_kiwi/test_sensor.py b/tests/components/electric_kiwi/test_sensor.py new file mode 100644 index 00000000000000..ef2687353344d2 --- /dev/null +++ b/tests/components/electric_kiwi/test_sensor.py @@ -0,0 +1,83 @@ +"""The tests for Electric Kiwi sensors.""" + + +from datetime import UTC, datetime +from unittest.mock import AsyncMock, Mock + +from freezegun import freeze_time +import pytest + +from homeassistant.components.electric_kiwi.const import ATTRIBUTION +from homeassistant.components.electric_kiwi.sensor import _check_and_move_time +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_ATTRIBUTION, ATTR_DEVICE_CLASS +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_registry import EntityRegistry +import homeassistant.util.dt as dt_util + +from .conftest import TIMEZONE, ComponentSetup, YieldFixture + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ("sensor", "sensor_state"), + [ + ("sensor.hour_of_free_power_start", "4:00 PM"), + ("sensor.hour_of_free_power_end", "5:00 PM"), + ], +) +async def test_hop_sensors( + hass: HomeAssistant, + config_entry: MockConfigEntry, + ek_api: YieldFixture, + ek_auth: YieldFixture, + entity_registry: EntityRegistry, + component_setup: ComponentSetup, + sensor: str, + sensor_state: str, +) -> None: + """Test HOP sensors for the Electric Kiwi integration. + + This time (note no day is given, it's only a time) is fed + from the Electric Kiwi API. if the API returns 4:00 PM, the + sensor state should be set to today at 4pm or if now is past 4pm, + then tomorrow at 4pm. + """ + assert await component_setup() + assert config_entry.state is ConfigEntryState.LOADED + + entity = entity_registry.async_get(sensor) + assert entity + + state = hass.states.get(sensor) + assert state + + api = ek_api(Mock()) + hop_data = await api.get_hop() + + value = _check_and_move_time(hop_data, sensor_state) + + value = value.astimezone(UTC) + assert state.state == value.isoformat(timespec="seconds") + assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TIMESTAMP + + +async def test_check_and_move_time(ek_api: AsyncMock) -> None: + """Test correct time is returned depending on time of day.""" + hop = await ek_api(Mock()).get_hop() + + test_time = datetime(2023, 6, 21, 18, 0, 0, tzinfo=TIMEZONE) + dt_util.set_default_time_zone(TIMEZONE) + + with freeze_time(test_time): + value = _check_and_move_time(hop, "4:00 PM") + assert str(value) == "2023-06-22 16:00:00+12:00" + + test_time = test_time.replace(hour=10) + + with freeze_time(test_time): + value = _check_and_move_time(hop, "4:00 PM") + assert str(value) == "2023-06-21 16:00:00+12:00" diff --git a/tests/components/elgato/test_config_flow.py b/tests/components/elgato/test_config_flow.py index 1b71a29632f789..bfae6fc9a17cce 100644 --- a/tests/components/elgato/test_config_flow.py +++ b/tests/components/elgato/test_config_flow.py @@ -1,4 +1,5 @@ """Tests for the Elgato Key Light config flow.""" +from ipaddress import ip_address from unittest.mock import AsyncMock, MagicMock from elgato import ElgatoConnectionError @@ -52,8 +53,8 @@ async def test_full_zeroconf_flow_implementation( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["127.0.0.1"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="example.local.", name="mock_name", port=9123, @@ -110,8 +111,8 @@ async def test_zeroconf_connection_error( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["127.0.0.1"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="mock_name", port=9123, @@ -150,8 +151,8 @@ async def test_zeroconf_device_exists_abort( DOMAIN, context={CONF_SOURCE: SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["127.0.0.1"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="mock_name", port=9123, @@ -171,8 +172,8 @@ async def test_zeroconf_device_exists_abort( DOMAIN, context={CONF_SOURCE: SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="127.0.0.2", - addresses=["127.0.0.2"], + ip_address=ip_address("127.0.0.2"), + ip_addresses=[ip_address("127.0.0.2")], hostname="mock_hostname", name="mock_name", port=9123, @@ -200,8 +201,8 @@ async def test_zeroconf_during_onboarding( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["127.0.0.1"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="example.local.", name="mock_name", port=9123, diff --git a/tests/components/enphase_envoy/test_config_flow.py b/tests/components/enphase_envoy/test_config_flow.py index a4481f4ed519e3..25517e390caf38 100644 --- a/tests/components/enphase_envoy/test_config_flow.py +++ b/tests/components/enphase_envoy/test_config_flow.py @@ -1,4 +1,5 @@ """Test the Enphase Envoy config flow.""" +from ipaddress import ip_address from unittest.mock import AsyncMock from pyenphase import EnvoyAuthenticationError, EnvoyError @@ -175,8 +176,8 @@ async def test_zeroconf_pre_token_firmware( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="1.1.1.1", - addresses=["1.1.1.1"], + ip_address=ip_address("1.1.1.1"), + ip_addresses=[ip_address("1.1.1.1")], hostname="mock_hostname", name="mock_name", port=None, @@ -216,8 +217,8 @@ async def test_zeroconf_token_firmware( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="1.1.1.1", - addresses=["1.1.1.1"], + ip_address=ip_address("1.1.1.1"), + ip_addresses=[ip_address("1.1.1.1")], hostname="mock_hostname", name="mock_name", port=None, @@ -278,8 +279,8 @@ async def test_zeroconf_serial_already_exists( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="4.4.4.4", - addresses=["4.4.4.4"], + ip_address=ip_address("4.4.4.4"), + ip_addresses=[ip_address("4.4.4.4")], hostname="mock_hostname", name="mock_name", port=None, @@ -301,8 +302,8 @@ async def test_zeroconf_serial_already_exists_ignores_ipv6( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="fd00::b27c:63bb:cc85:4ea0", - addresses=["fd00::b27c:63bb:cc85:4ea0"], + ip_address=ip_address("fd00::b27c:63bb:cc85:4ea0"), + ip_addresses=[ip_address("fd00::b27c:63bb:cc85:4ea0")], hostname="mock_hostname", name="mock_name", port=None, @@ -325,8 +326,8 @@ async def test_zeroconf_host_already_exists( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="1.1.1.1", - addresses=["1.1.1.1"], + ip_address=ip_address("1.1.1.1"), + ip_addresses=[ip_address("1.1.1.1")], hostname="mock_hostname", name="mock_name", port=None, diff --git a/tests/components/esphome/test_config_flow.py b/tests/components/esphome/test_config_flow.py index 63e181076235ba..01ba07852d6609 100644 --- a/tests/components/esphome/test_config_flow.py +++ b/tests/components/esphome/test_config_flow.py @@ -1,5 +1,6 @@ """Test config flow.""" import asyncio +from ipaddress import ip_address import json from unittest.mock import AsyncMock, MagicMock, patch @@ -121,8 +122,8 @@ async def test_user_sets_unique_id( ) -> None: """Test that the user flow sets the unique id.""" service_info = zeroconf.ZeroconfServiceInfo( - host="192.168.43.183", - addresses=["192.168.43.183"], + ip_address=ip_address("192.168.43.183"), + ip_addresses=[ip_address("192.168.43.183")], hostname="test8266.local.", name="mock_name", port=6053, @@ -198,8 +199,8 @@ async def test_user_causes_zeroconf_to_abort( ) -> None: """Test that the user flow sets the unique id and aborts the zeroconf flow.""" service_info = zeroconf.ZeroconfServiceInfo( - host="192.168.43.183", - addresses=["192.168.43.183"], + ip_address=ip_address("192.168.43.183"), + ip_addresses=[ip_address("192.168.43.183")], hostname="test8266.local.", name="mock_name", port=6053, @@ -558,8 +559,8 @@ async def test_discovery_initiation( ) -> None: """Test discovery importing works.""" service_info = zeroconf.ZeroconfServiceInfo( - host="192.168.43.183", - addresses=["192.168.43.183"], + ip_address=ip_address("192.168.43.183"), + ip_addresses=[ip_address("192.168.43.183")], hostname="test.local.", name="mock_name", port=6053, @@ -590,8 +591,8 @@ async def test_discovery_no_mac( ) -> None: """Test discovery aborted if old ESPHome without mac in zeroconf.""" service_info = zeroconf.ZeroconfServiceInfo( - host="192.168.43.183", - addresses=["192.168.43.183"], + ip_address=ip_address("192.168.43.183"), + ip_addresses=[ip_address("192.168.43.183")], hostname="test8266.local.", name="mock_name", port=6053, @@ -618,8 +619,8 @@ async def test_discovery_already_configured( entry.add_to_hass(hass) service_info = zeroconf.ZeroconfServiceInfo( - host="192.168.43.183", - addresses=["192.168.43.183"], + ip_address=ip_address("192.168.43.183"), + ip_addresses=[ip_address("192.168.43.183")], hostname="test8266.local.", name="mock_name", port=6053, @@ -639,8 +640,8 @@ async def test_discovery_duplicate_data( ) -> None: """Test discovery aborts if same mDNS packet arrives.""" service_info = zeroconf.ZeroconfServiceInfo( - host="192.168.43.183", - addresses=["192.168.43.183"], + ip_address=ip_address("192.168.43.183"), + ip_addresses=[ip_address("192.168.43.183")], hostname="test.local.", name="mock_name", port=6053, @@ -674,8 +675,8 @@ async def test_discovery_updates_unique_id( entry.add_to_hass(hass) service_info = zeroconf.ZeroconfServiceInfo( - host="192.168.43.183", - addresses=["192.168.43.183"], + ip_address=ip_address("192.168.43.183"), + ip_addresses=[ip_address("192.168.43.183")], hostname="test8266.local.", name="mock_name", port=6053, @@ -1173,8 +1174,8 @@ async def test_zeroconf_encryption_key_via_dashboard( ) -> None: """Test encryption key retrieved from dashboard.""" service_info = zeroconf.ZeroconfServiceInfo( - host="192.168.43.183", - addresses=["192.168.43.183"], + ip_address=ip_address("192.168.43.183"), + ip_addresses=[ip_address("192.168.43.183")], hostname="test8266.local.", name="mock_name", port=6053, @@ -1239,8 +1240,8 @@ async def test_zeroconf_encryption_key_via_dashboard_with_api_encryption_prop( ) -> None: """Test encryption key retrieved from dashboard with api_encryption property set.""" service_info = zeroconf.ZeroconfServiceInfo( - host="192.168.43.183", - addresses=["192.168.43.183"], + ip_address=ip_address("192.168.43.183"), + ip_addresses=[ip_address("192.168.43.183")], hostname="test8266.local.", name="mock_name", port=6053, @@ -1305,8 +1306,8 @@ async def test_zeroconf_no_encryption_key_via_dashboard( ) -> None: """Test encryption key not retrieved from dashboard.""" service_info = zeroconf.ZeroconfServiceInfo( - host="192.168.43.183", - addresses=["192.168.43.183"], + ip_address=ip_address("192.168.43.183"), + ip_addresses=[ip_address("192.168.43.183")], hostname="test8266.local.", name="mock_name", port=6053, diff --git a/tests/components/fibaro/conftest.py b/tests/components/fibaro/conftest.py index 8a2bbcbcd4a68e..1a3f9b083b8686 100644 --- a/tests/components/fibaro/conftest.py +++ b/tests/components/fibaro/conftest.py @@ -5,7 +5,7 @@ from pyfibaro.fibaro_scene import SceneModel import pytest -from homeassistant.components.fibaro import DOMAIN, FIBARO_CONTROLLER, FIBARO_DEVICES +from homeassistant.components.fibaro import DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -47,16 +47,12 @@ async def setup_platform( controller_mock = Mock() controller_mock.hub_serial = "HC2-111111" controller_mock.get_room_name.return_value = room_name + controller_mock.fibaro_devices = {Platform.SCENE: scenes} for scene in scenes: scene.fibaro_controller = controller_mock - hass.data[DOMAIN] = { - config_entry.entry_id: { - FIBARO_CONTROLLER: controller_mock, - FIBARO_DEVICES: {Platform.SCENE: scenes}, - } - } + hass.data[DOMAIN] = {config_entry.entry_id: controller_mock} await hass.config_entries.async_forward_entry_setup(config_entry, platform) await hass.async_block_till_done() return config_entry diff --git a/tests/components/flipr/test_init.py b/tests/components/flipr/test_init.py index e9685bd6e0a941..c1c5c0086e7692 100644 --- a/tests/components/flipr/test_init.py +++ b/tests/components/flipr/test_init.py @@ -21,7 +21,7 @@ async def test_unload_entry(hass: HomeAssistant) -> None: unique_id="123456", ) entry.add_to_hass(hass) - with patch("homeassistant.components.flipr.FliprAPIRestClient"): + with patch("homeassistant.components.flipr.coordinator.FliprAPIRestClient"): await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() await hass.config_entries.async_unload(entry.entry_id) diff --git a/tests/components/forked_daapd/test_config_flow.py b/tests/components/forked_daapd/test_config_flow.py index fc02cdb4123194..080e47acc3e8f2 100644 --- a/tests/components/forked_daapd/test_config_flow.py +++ b/tests/components/forked_daapd/test_config_flow.py @@ -1,4 +1,5 @@ """The config flow tests for the forked_daapd media player platform.""" +from ipaddress import ip_address from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -103,8 +104,8 @@ async def test_zeroconf_updates_title(hass: HomeAssistant, config_entry) -> None config_entry.add_to_hass(hass) assert len(hass.config_entries.async_entries(DOMAIN)) == 2 discovery_info = zeroconf.ZeroconfServiceInfo( - host="192.168.1.1", - addresses=["192.168.1.1"], + ip_address=ip_address("192.168.1.1"), + ip_addresses=[ip_address("192.168.1.1")], hostname="mock_hostname", name="mock_name", port=23, @@ -138,8 +139,8 @@ async def test_config_flow_zeroconf_invalid(hass: HomeAssistant) -> None: """Test that an invalid zeroconf entry doesn't work.""" # test with no discovery properties discovery_info = zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["127.0.0.1"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="mock_name", port=23, @@ -153,8 +154,8 @@ async def test_config_flow_zeroconf_invalid(hass: HomeAssistant) -> None: assert result["reason"] == "not_forked_daapd" # test with forked-daapd version < 27 discovery_info = zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["127.0.0.1"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="mock_name", port=23, @@ -168,8 +169,8 @@ async def test_config_flow_zeroconf_invalid(hass: HomeAssistant) -> None: assert result["reason"] == "not_forked_daapd" # test with verbose mtd-version from Firefly discovery_info = zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["127.0.0.1"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="mock_name", port=23, @@ -183,8 +184,8 @@ async def test_config_flow_zeroconf_invalid(hass: HomeAssistant) -> None: assert result["reason"] == "not_forked_daapd" # test with svn mtd-version from Firefly discovery_info = zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["127.0.0.1"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="mock_name", port=23, @@ -201,8 +202,8 @@ async def test_config_flow_zeroconf_invalid(hass: HomeAssistant) -> None: async def test_config_flow_zeroconf_valid(hass: HomeAssistant) -> None: """Test that a valid zeroconf entry works.""" discovery_info = zeroconf.ZeroconfServiceInfo( - host="192.168.1.1", - addresses=["192.168.1.1"], + ip_address=ip_address("192.168.1.1"), + ip_addresses=[ip_address("192.168.1.1")], hostname="mock_hostname", name="mock_name", port=23, diff --git a/tests/components/freebox/common.py b/tests/components/freebox/common.py new file mode 100644 index 00000000000000..9f7dfd8f92a029 --- /dev/null +++ b/tests/components/freebox/common.py @@ -0,0 +1,27 @@ +"""Common methods used across tests for Freebox.""" +from unittest.mock import patch + +from homeassistant.components.freebox.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from .const import MOCK_HOST, MOCK_PORT + +from tests.common import MockConfigEntry + + +async def setup_platform(hass: HomeAssistant, platform: str) -> MockConfigEntry: + """Set up the Freebox platform.""" + mock_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: MOCK_HOST, CONF_PORT: MOCK_PORT}, + unique_id=MOCK_HOST, + ) + mock_entry.add_to_hass(hass) + + with patch("homeassistant.components.freebox.PLATFORMS", [platform]): + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + return mock_entry diff --git a/tests/components/freebox/const.py b/tests/components/freebox/const.py index a6253dbf3154ec..0b58348a5dfb86 100644 --- a/tests/components/freebox/const.py +++ b/tests/components/freebox/const.py @@ -1986,7 +1986,7 @@ "category": "kfb", "group": {"label": ""}, "id": 9, - "label": "Télécommande I", + "label": "Télécommande", "name": "node_9", "props": { "Address": 5, @@ -2067,7 +2067,7 @@ "category": "dws", "group": {"label": "Entrée"}, "id": 11, - "label": "dws i", + "label": "Ouverture porte", "name": "node_11", "props": { "Address": 6, @@ -2259,7 +2259,7 @@ "category": "pir", "group": {"label": "Salon"}, "id": 26, - "label": "Salon Détecteur s", + "label": "Détecteur", "name": "node_26", "props": { "Address": 9, diff --git a/tests/components/freebox/test_binary_sensor.py b/tests/components/freebox/test_binary_sensor.py index ec504a514adebc..218ef953ee0523 100644 --- a/tests/components/freebox/test_binary_sensor.py +++ b/tests/components/freebox/test_binary_sensor.py @@ -1,29 +1,24 @@ """Tests for the Freebox sensors.""" from copy import deepcopy -from datetime import timedelta from unittest.mock import Mock -from homeassistant.components.freebox.const import DOMAIN -from homeassistant.const import CONF_HOST, CONF_PORT +from freezegun.api import FrozenDateTimeFactory + +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.freebox import SCAN_INTERVAL from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util -from .const import DATA_STORAGE_GET_RAIDS, MOCK_HOST, MOCK_PORT +from .common import setup_platform +from .const import DATA_STORAGE_GET_RAIDS -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import async_fire_time_changed -async def test_raid_array_degraded(hass: HomeAssistant, router: Mock) -> None: +async def test_raid_array_degraded( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, router: Mock +) -> None: """Test raid array degraded binary sensor.""" - entry = MockConfigEntry( - domain=DOMAIN, - data={CONF_HOST: MOCK_HOST, CONF_PORT: MOCK_PORT}, - unique_id=MOCK_HOST, - ) - entry.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + await setup_platform(hass, BINARY_SENSOR_DOMAIN) assert ( hass.states.get("binary_sensor.freebox_server_r2_raid_array_0_degraded").state @@ -35,7 +30,8 @@ async def test_raid_array_degraded(hass: HomeAssistant, router: Mock) -> None: data_storage_get_raids_degraded[0]["degraded"] = True router().storage.get_raids.return_value = data_storage_get_raids_degraded # Simulate an update - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=60)) + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) # To execute the save await hass.async_block_till_done() assert ( diff --git a/tests/components/freebox/test_button.py b/tests/components/freebox/test_button.py index de15e90f54fa92..5f72b5968f1b3d 100644 --- a/tests/components/freebox/test_button.py +++ b/tests/components/freebox/test_button.py @@ -1,29 +1,19 @@ """Tests for the Freebox config flow.""" -from unittest.mock import ANY, Mock, patch +from unittest.mock import ANY, AsyncMock, Mock, patch from pytest_unordered import unordered from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS -from homeassistant.components.freebox.const import DOMAIN -from homeassistant.const import ATTR_ENTITY_ID, CONF_HOST, CONF_PORT +from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component -from .const import MOCK_HOST, MOCK_PORT +from .common import setup_platform -from tests.common import MockConfigEntry - -async def test_reboot_button(hass: HomeAssistant, router: Mock) -> None: +async def test_reboot(hass: HomeAssistant, router: Mock) -> None: """Test reboot button.""" - entry = MockConfigEntry( - domain=DOMAIN, - data={CONF_HOST: MOCK_HOST, CONF_PORT: MOCK_PORT}, - unique_id=MOCK_HOST, - ) - entry.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + entry = await setup_platform(hass, BUTTON_DOMAIN) + assert hass.config_entries.async_entries() == unordered([entry, ANY]) assert router.call_count == 1 @@ -32,6 +22,7 @@ async def test_reboot_button(hass: HomeAssistant, router: Mock) -> None: with patch( "homeassistant.components.freebox.router.FreeboxRouter.reboot" ) as mock_service: + mock_service.assert_not_called() await hass.services.async_call( BUTTON_DOMAIN, SERVICE_PRESS, @@ -42,3 +33,29 @@ async def test_reboot_button(hass: HomeAssistant, router: Mock) -> None: ) await hass.async_block_till_done() mock_service.assert_called_once() + + +async def test_mark_calls_as_read(hass: HomeAssistant, router: Mock) -> None: + """Test mark calls as read button.""" + entry = await setup_platform(hass, BUTTON_DOMAIN) + + assert hass.config_entries.async_entries() == unordered([entry, ANY]) + + assert router.call_count == 1 + assert router().open.call_count == 1 + + with patch( + "homeassistant.components.freebox.router.FreeboxRouter.call" + ) as mock_service: + mock_service.mark_calls_log_as_read = AsyncMock() + mock_service.mark_calls_log_as_read.assert_not_called() + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + service_data={ + ATTR_ENTITY_ID: "button.mark_calls_as_read", + }, + blocking=True, + ) + await hass.async_block_till_done() + mock_service.mark_calls_log_as_read.assert_called_once() diff --git a/tests/components/freebox/test_config_flow.py b/tests/components/freebox/test_config_flow.py index d8ea7107f23e9b..9d6f95b2559b49 100644 --- a/tests/components/freebox/test_config_flow.py +++ b/tests/components/freebox/test_config_flow.py @@ -1,4 +1,5 @@ """Tests for the Freebox config flow.""" +from ipaddress import ip_address from unittest.mock import Mock, patch from freebox_api.exceptions import ( @@ -19,8 +20,8 @@ from tests.common import MockConfigEntry MOCK_ZEROCONF_DATA = zeroconf.ZeroconfServiceInfo( - host="192.168.0.254", - addresses=["192.168.0.254"], + ip_address=ip_address("192.168.0.254"), + ip_addresses=[ip_address("192.168.0.254")], port=80, hostname="Freebox-Server.local.", type="_fbx-api._tcp.local.", diff --git a/tests/components/freebox/test_sensor.py b/tests/components/freebox/test_sensor.py new file mode 100644 index 00000000000000..801e8508d86d1a --- /dev/null +++ b/tests/components/freebox/test_sensor.py @@ -0,0 +1,117 @@ +"""Tests for the Freebox sensors.""" +from copy import deepcopy +from unittest.mock import Mock + +from freezegun.api import FrozenDateTimeFactory + +from homeassistant.components.freebox import SCAN_INTERVAL +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.core import HomeAssistant + +from .common import setup_platform +from .const import ( + DATA_CONNECTION_GET_STATUS, + DATA_HOME_GET_NODES, + DATA_STORAGE_GET_DISKS, +) + +from tests.common import async_fire_time_changed + + +async def test_network_speed( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, router: Mock +) -> None: + """Test missed call sensor.""" + await setup_platform(hass, SENSOR_DOMAIN) + + assert hass.states.get("sensor.freebox_download_speed").state == "198.9" + assert hass.states.get("sensor.freebox_upload_speed").state == "1440.0" + + # Simulate a changed speed + data_connection_get_status_changed = deepcopy(DATA_CONNECTION_GET_STATUS) + data_connection_get_status_changed["rate_down"] = 123400 + data_connection_get_status_changed["rate_up"] = 432100 + router().connection.get_status.return_value = data_connection_get_status_changed + # Simulate an update + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + # To execute the save + await hass.async_block_till_done() + assert hass.states.get("sensor.freebox_download_speed").state == "123.4" + assert hass.states.get("sensor.freebox_upload_speed").state == "432.1" + + +async def test_call( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, router: Mock +) -> None: + """Test missed call sensor.""" + await setup_platform(hass, SENSOR_DOMAIN) + + assert hass.states.get("sensor.freebox_missed_calls").state == "3" + + # Simulate we marked calls as read + data_call_get_calls_marked_as_read = [] + router().call.get_calls_log.return_value = data_call_get_calls_marked_as_read + # Simulate an update + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + # To execute the save + await hass.async_block_till_done() + assert hass.states.get("sensor.freebox_missed_calls").state == "0" + + +async def test_disk( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, router: Mock +) -> None: + """Test disk sensor.""" + await setup_platform(hass, SENSOR_DOMAIN) + + # Initial state + assert ( + router().storage.get_disks.return_value[2]["partitions"][0]["total_bytes"] + == 1960000000000 + ) + + assert ( + router().storage.get_disks.return_value[2]["partitions"][0]["free_bytes"] + == 1730000000000 + ) + + assert hass.states.get("sensor.freebox_free_space").state == "88.27" + + # Simulate a changed storage size + data_storage_get_disks_changed = deepcopy(DATA_STORAGE_GET_DISKS) + data_storage_get_disks_changed[2]["partitions"][0]["free_bytes"] = 880000000000 + router().storage.get_disks.return_value = data_storage_get_disks_changed + # Simulate an update + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + # To execute the save + await hass.async_block_till_done() + assert hass.states.get("sensor.freebox_free_space").state == "44.9" + + +async def test_battery( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, router: Mock +) -> None: + """Test battery sensor.""" + await setup_platform(hass, SENSOR_DOMAIN) + + assert hass.states.get("sensor.telecommande_niveau_de_batterie").state == "100" + assert hass.states.get("sensor.ouverture_porte_niveau_de_batterie").state == "100" + assert hass.states.get("sensor.detecteur_niveau_de_batterie").state == "100" + + # Simulate a changed battery + data_home_get_nodes_changed = deepcopy(DATA_HOME_GET_NODES) + data_home_get_nodes_changed[2]["show_endpoints"][3]["value"] = 25 + data_home_get_nodes_changed[3]["show_endpoints"][3]["value"] = 50 + data_home_get_nodes_changed[4]["show_endpoints"][3]["value"] = 75 + router().home.get_home_nodes.return_value = data_home_get_nodes_changed + # Simulate an update + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + # To execute the save + await hass.async_block_till_done() + assert hass.states.get("sensor.telecommande_niveau_de_batterie").state == "25" + assert hass.states.get("sensor.ouverture_porte_niveau_de_batterie").state == "50" + assert hass.states.get("sensor.detecteur_niveau_de_batterie").state == "75" diff --git a/tests/components/fritz/test_update.py b/tests/components/fritz/test_update.py index dbff4713553c21..bc677e28ebe099 100644 --- a/tests/components/fritz/test_update.py +++ b/tests/components/fritz/test_update.py @@ -8,11 +8,25 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from .const import MOCK_FIRMWARE_AVAILABLE, MOCK_FIRMWARE_RELEASE_URL, MOCK_USER_DATA +from .const import ( + MOCK_FB_SERVICES, + MOCK_FIRMWARE_AVAILABLE, + MOCK_FIRMWARE_RELEASE_URL, + MOCK_USER_DATA, +) from tests.common import MockConfigEntry from tests.typing import ClientSessionGenerator +AVAILABLE_UPDATE = { + "UserInterface1": { + "GetInfo": { + "NewX_AVM-DE_Version": MOCK_FIRMWARE_AVAILABLE, + "NewX_AVM-DE_InfoURL": MOCK_FIRMWARE_RELEASE_URL, + }, + } +} + async def test_update_entities_initialized( hass: HomeAssistant, @@ -41,23 +55,21 @@ async def test_update_available( ) -> None: """Test update entities.""" - with patch( - "homeassistant.components.fritz.common.FritzBoxTools._update_device_info", - return_value=(True, MOCK_FIRMWARE_AVAILABLE, MOCK_FIRMWARE_RELEASE_URL), - ): - entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) - entry.add_to_hass(hass) + fc_class_mock().override_services({**MOCK_FB_SERVICES, **AVAILABLE_UPDATE}) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - assert entry.state == ConfigEntryState.LOADED + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) + entry.add_to_hass(hass) - update = hass.states.get("update.mock_title_fritz_os") - assert update is not None - assert update.state == "on" - assert update.attributes.get("installed_version") == "7.29" - assert update.attributes.get("latest_version") == MOCK_FIRMWARE_AVAILABLE - assert update.attributes.get("release_url") == MOCK_FIRMWARE_RELEASE_URL + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + assert entry.state == ConfigEntryState.LOADED + + update = hass.states.get("update.mock_title_fritz_os") + assert update is not None + assert update.state == "on" + assert update.attributes.get("installed_version") == "7.29" + assert update.attributes.get("latest_version") == MOCK_FIRMWARE_AVAILABLE + assert update.attributes.get("release_url") == MOCK_FIRMWARE_RELEASE_URL async def test_no_update_available( @@ -90,10 +102,9 @@ async def test_available_update_can_be_installed( ) -> None: """Test update entities.""" + fc_class_mock().override_services({**MOCK_FB_SERVICES, **AVAILABLE_UPDATE}) + with patch( - "homeassistant.components.fritz.common.FritzBoxTools._update_device_info", - return_value=(True, MOCK_FIRMWARE_AVAILABLE, MOCK_FIRMWARE_RELEASE_URL), - ), patch( "homeassistant.components.fritz.common.FritzBoxTools.async_trigger_firmware_update", return_value=True, ) as mocked_update_call: diff --git a/tests/components/glances/test_config_flow.py b/tests/components/glances/test_config_flow.py index 187e319fe08a96..d4d25d8b86f6ff 100644 --- a/tests/components/glances/test_config_flow.py +++ b/tests/components/glances/test_config_flow.py @@ -35,7 +35,7 @@ async def test_form(hass: HomeAssistant) -> None: ) assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == "0.0.0.0" + assert result["title"] == "0.0.0.0:61208" assert result["data"] == MOCK_USER_INPUT diff --git a/tests/components/gogogate2/test_config_flow.py b/tests/components/gogogate2/test_config_flow.py index 32d0f197bb5ee9..6de041257834bd 100644 --- a/tests/components/gogogate2/test_config_flow.py +++ b/tests/components/gogogate2/test_config_flow.py @@ -1,4 +1,5 @@ """Tests for the GogoGate2 component.""" +from ipaddress import ip_address from unittest.mock import MagicMock, patch from ismartgate import GogoGate2Api, ISmartGateApi @@ -104,8 +105,8 @@ async def test_form_homekit_unique_id_already_setup(hass: HomeAssistant) -> None DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, data=zeroconf.ZeroconfServiceInfo( - host="1.2.3.4", - addresses=["1.2.3.4"], + ip_address=ip_address("1.2.3.4"), + ip_addresses=[ip_address("1.2.3.4")], hostname="mock_hostname", name="mock_name", port=None, @@ -132,8 +133,8 @@ async def test_form_homekit_unique_id_already_setup(hass: HomeAssistant) -> None DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, data=zeroconf.ZeroconfServiceInfo( - host="1.2.3.4", - addresses=["1.2.3.4"], + ip_address=ip_address("1.2.3.4"), + ip_addresses=[ip_address("1.2.3.4")], hostname="mock_hostname", name="mock_name", port=None, @@ -157,8 +158,8 @@ async def test_form_homekit_ip_address_already_setup(hass: HomeAssistant) -> Non DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, data=zeroconf.ZeroconfServiceInfo( - host="1.2.3.4", - addresses=["1.2.3.4"], + ip_address=ip_address("1.2.3.4"), + ip_addresses=[ip_address("1.2.3.4")], hostname="mock_hostname", name="mock_name", port=None, @@ -176,8 +177,8 @@ async def test_form_homekit_ip_address(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, data=zeroconf.ZeroconfServiceInfo( - host="1.2.3.4", - addresses=["1.2.3.4"], + ip_address=ip_address("1.2.3.4"), + ip_addresses=[ip_address("1.2.3.4")], hostname="mock_hostname", name="mock_name", port=None, @@ -259,8 +260,8 @@ async def test_discovered_by_homekit_and_dhcp(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, data=zeroconf.ZeroconfServiceInfo( - host="1.2.3.4", - addresses=["1.2.3.4"], + ip_address=ip_address("1.2.3.4"), + ip_addresses=[ip_address("1.2.3.4")], hostname="mock_hostname", name="mock_name", port=None, diff --git a/tests/components/google/test_init.py b/tests/components/google/test_init.py index 17f300f58cb51b..233635510e07ae 100644 --- a/tests/components/google/test_init.py +++ b/tests/components/google/test_init.py @@ -20,7 +20,7 @@ from homeassistant.const import ATTR_FRIENDLY_NAME, STATE_OFF from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import HomeAssistantError -from homeassistant.util.dt import utcnow +from homeassistant.util.dt import UTC, utcnow from .conftest import ( CALENDAR_ID, @@ -645,7 +645,8 @@ async def test_add_event_location( @pytest.mark.parametrize( - "config_entry_token_expiry", [datetime.datetime.max.timestamp() + 1] + "config_entry_token_expiry", + [datetime.datetime.max.replace(tzinfo=UTC).timestamp() + 1], ) async def test_invalid_token_expiry_in_config_entry( hass: HomeAssistant, diff --git a/tests/components/google_assistant/test_helpers.py b/tests/components/google_assistant/test_helpers.py index 17df677110b223..001e8ff0d075e5 100644 --- a/tests/components/google_assistant/test_helpers.py +++ b/tests/components/google_assistant/test_helpers.py @@ -447,32 +447,53 @@ async def test_config_local_sdk_warn_version( ) in caplog.text -def test_is_supported_cached() -> None: - """Test is_supported is cached.""" +def test_async_get_entities_cached(hass: HomeAssistant) -> None: + """Test async_get_entities is cached.""" config = MockConfig() - def entity(features: int): - return helpers.GoogleEntity( - None, - config, - State("test.entity_id", "on", {"supported_features": features}), - ) + hass.states.async_set("light.ceiling_lights", "off") + hass.states.async_set("light.bed_light", "off") + hass.states.async_set("not_supported.not_supported", "off") + + google_entities = helpers.async_get_entities(hass, config) + assert len(google_entities) == 2 + assert config.is_supported_cache == { + "light.bed_light": (None, True), + "light.ceiling_lights": (None, True), + "not_supported.not_supported": (None, False), + } with patch( "homeassistant.components.google_assistant.helpers.GoogleEntity.traits", - return_value=[1], - ) as mock_traits: - assert entity(1).is_supported() is True - assert len(mock_traits.mock_calls) == 1 + return_value=RuntimeError("Should not be called"), + ): + google_entities = helpers.async_get_entities(hass, config) - # Supported feature changes, so we calculate again - assert entity(2).is_supported() is True - assert len(mock_traits.mock_calls) == 2 + assert len(google_entities) == 2 + assert config.is_supported_cache == { + "light.bed_light": (None, True), + "light.ceiling_lights": (None, True), + "not_supported.not_supported": (None, False), + } + + hass.states.async_set("light.new", "on") + google_entities = helpers.async_get_entities(hass, config) - mock_traits.reset_mock() + assert len(google_entities) == 3 + assert config.is_supported_cache == { + "light.bed_light": (None, True), + "light.new": (None, True), + "light.ceiling_lights": (None, True), + "not_supported.not_supported": (None, False), + } - # Supported feature is same, so we do not calculate again - mock_traits.side_effect = ValueError + hass.states.async_set("light.new", "on", {"supported_features": 1}) + google_entities = helpers.async_get_entities(hass, config) - assert entity(2).is_supported() is True - assert len(mock_traits.mock_calls) == 0 + assert len(google_entities) == 3 + assert config.is_supported_cache == { + "light.bed_light": (None, True), + "light.new": (1, True), + "light.ceiling_lights": (None, True), + "not_supported.not_supported": (None, False), + } diff --git a/tests/components/google_assistant/test_report_state.py b/tests/components/google_assistant/test_report_state.py index 3fe2a749fcad16..d6f4043d2f7d22 100644 --- a/tests/components/google_assistant/test_report_state.py +++ b/tests/components/google_assistant/test_report_state.py @@ -69,7 +69,7 @@ async def test_report_state( # Test that if serialize returns same value, we don't send with patch( - "homeassistant.components.google_assistant.report_state.GoogleEntity.query_serialize", + "homeassistant.components.google_assistant.helpers.GoogleEntity.query_serialize", return_value={"same": "info"}, ), patch.object(BASIC_CONFIG, "async_report_state_all", AsyncMock()) as mock_report: # New state, so reported @@ -104,7 +104,7 @@ async def test_report_state( with patch.object( BASIC_CONFIG, "async_report_state_all", AsyncMock() ) as mock_report, patch( - "homeassistant.components.google_assistant.report_state.GoogleEntity.query_serialize", + "homeassistant.components.google_assistant.helpers.GoogleEntity.query_serialize", side_effect=error.SmartHomeError("mock-error", "mock-msg"), ): hass.states.async_set("light.kitchen", "off") diff --git a/tests/components/group/test_config_flow.py b/tests/components/group/test_config_flow.py index d0e90fe61bdc63..1c8275c7f2deb5 100644 --- a/tests/components/group/test_config_flow.py +++ b/tests/components/group/test_config_flow.py @@ -468,7 +468,7 @@ async def test_options_flow_hides_members( COVER_ATTRS = [{"supported_features": 0}, {}] EVENT_ATTRS = [{"event_types": []}, {"event_type": None}] -FAN_ATTRS = [{"supported_features": 0}, {"assumed_state": True}] +FAN_ATTRS = [{"supported_features": 0}, {}] LIGHT_ATTRS = [ { "icon": "mdi:lightbulb-group", diff --git a/tests/components/group/test_cover.py b/tests/components/group/test_cover.py index 84ccba2ff6663a..4e0ddc19a312af 100644 --- a/tests/components/group/test_cover.py +++ b/tests/components/group/test_cover.py @@ -346,10 +346,10 @@ async def test_attributes(hass: HomeAssistant, setup_comp) -> None: assert state.attributes[ATTR_CURRENT_POSITION] == 70 assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 60 - # ### Test assumed state ### + # ### Test state when group members have different states ### # ########################## - # For covers - assumed state set true if position differ + # Covers hass.states.async_set( DEMO_COVER, STATE_OPEN, {ATTR_SUPPORTED_FEATURES: 4, ATTR_CURRENT_POSITION: 100} ) @@ -357,7 +357,7 @@ async def test_attributes(hass: HomeAssistant, setup_comp) -> None: state = hass.states.get(COVER_GROUP) assert state.state == STATE_OPEN - assert state.attributes[ATTR_ASSUMED_STATE] is True + assert ATTR_ASSUMED_STATE not in state.attributes assert state.attributes[ATTR_SUPPORTED_FEATURES] == 244 assert state.attributes[ATTR_CURRENT_POSITION] == 85 # (70 + 100) / 2 assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 60 @@ -373,7 +373,7 @@ async def test_attributes(hass: HomeAssistant, setup_comp) -> None: assert ATTR_CURRENT_POSITION not in state.attributes assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 60 - # For tilts - assumed state set true if tilt position differ + # Tilts hass.states.async_set( DEMO_TILT, STATE_OPEN, @@ -383,7 +383,7 @@ async def test_attributes(hass: HomeAssistant, setup_comp) -> None: state = hass.states.get(COVER_GROUP) assert state.state == STATE_OPEN - assert state.attributes[ATTR_ASSUMED_STATE] is True + assert ATTR_ASSUMED_STATE not in state.attributes assert state.attributes[ATTR_SUPPORTED_FEATURES] == 128 assert ATTR_CURRENT_POSITION not in state.attributes assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 80 # (60 + 100) / 2 @@ -399,11 +399,12 @@ async def test_attributes(hass: HomeAssistant, setup_comp) -> None: assert ATTR_CURRENT_POSITION not in state.attributes assert ATTR_CURRENT_TILT_POSITION not in state.attributes + # Group member has set assumed_state hass.states.async_set(DEMO_TILT, STATE_CLOSED, {ATTR_ASSUMED_STATE: True}) await hass.async_block_till_done() state = hass.states.get(COVER_GROUP) - assert state.attributes[ATTR_ASSUMED_STATE] is True + assert ATTR_ASSUMED_STATE not in state.attributes # Test entity registry integration entity_registry = er.async_get(hass) diff --git a/tests/components/group/test_fan.py b/tests/components/group/test_fan.py index 6269df3fed7553..2272a29f6edf3f 100644 --- a/tests/components/group/test_fan.py +++ b/tests/components/group/test_fan.py @@ -247,11 +247,7 @@ async def test_attributes(hass: HomeAssistant, setup_comp) -> None: assert state.attributes[ATTR_PERCENTAGE] == 50 assert ATTR_ASSUMED_STATE not in state.attributes - # Add Entity that supports - # ### Test assumed state ### - # ########################## - - # Add Entity with a different speed should set assumed state + # Add Entity with a different speed should not set assumed state hass.states.async_set( PERCENTAGE_LIMITED_FAN_ENTITY_ID, STATE_ON, @@ -264,7 +260,7 @@ async def test_attributes(hass: HomeAssistant, setup_comp) -> None: state = hass.states.get(FAN_GROUP) assert state.state == STATE_ON - assert state.attributes[ATTR_ASSUMED_STATE] is True + assert ATTR_ASSUMED_STATE not in state.attributes assert state.attributes[ATTR_PERCENTAGE] == int((50 + 75) / 2) @@ -306,11 +302,7 @@ async def test_direction_oscillating(hass: HomeAssistant, setup_comp) -> None: assert state.attributes[ATTR_DIRECTION] == DIRECTION_FORWARD assert ATTR_ASSUMED_STATE not in state.attributes - # Add Entity that supports - # ### Test assumed state ### - # ########################## - - # Add Entity with a different direction should set assumed state + # Add Entity with a different direction should not set assumed state hass.states.async_set( PERCENTAGE_FULL_FAN_ENTITY_ID, STATE_ON, @@ -325,11 +317,10 @@ async def test_direction_oscillating(hass: HomeAssistant, setup_comp) -> None: state = hass.states.get(FAN_GROUP) assert state.state == STATE_ON - assert state.attributes[ATTR_ASSUMED_STATE] is True + assert ATTR_ASSUMED_STATE not in state.attributes assert ATTR_PERCENTAGE in state.attributes assert state.attributes[ATTR_PERCENTAGE] == 50 assert state.attributes[ATTR_OSCILLATING] is True - assert ATTR_ASSUMED_STATE in state.attributes # Now that everything is the same, no longer assumed state diff --git a/tests/components/guardian/test_config_flow.py b/tests/components/guardian/test_config_flow.py index cb28ea22a379b3..3d0be516deaf74 100644 --- a/tests/components/guardian/test_config_flow.py +++ b/tests/components/guardian/test_config_flow.py @@ -1,4 +1,5 @@ """Define tests for the Elexa Guardian config flow.""" +from ipaddress import ip_address from unittest.mock import patch from aioguardian.errors import GuardianError @@ -79,8 +80,8 @@ async def test_step_user(hass: HomeAssistant, config, setup_guardian) -> None: async def test_step_zeroconf(hass: HomeAssistant, setup_guardian) -> None: """Test the zeroconf step.""" zeroconf_data = zeroconf.ZeroconfServiceInfo( - host="192.168.1.100", - addresses=["192.168.1.100"], + ip_address=ip_address("192.168.1.100"), + ip_addresses=[ip_address("192.168.1.100")], port=7777, hostname="GVC1-ABCD.local.", type="_api._udp.local.", @@ -109,8 +110,8 @@ async def test_step_zeroconf(hass: HomeAssistant, setup_guardian) -> None: async def test_step_zeroconf_already_in_progress(hass: HomeAssistant) -> None: """Test the zeroconf step aborting because it's already in progress.""" zeroconf_data = zeroconf.ZeroconfServiceInfo( - host="192.168.1.100", - addresses=["192.168.1.100"], + ip_address=ip_address("192.168.1.100"), + ip_addresses=[ip_address("192.168.1.100")], port=7777, hostname="GVC1-ABCD.local.", type="_api._udp.local.", diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index 31ee73013dad28..48f52ee7c2416e 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -633,6 +633,41 @@ async def test_invalid_service_calls( ) +async def test_addon_service_call_with_complex_slug( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Addon slugs can have ., - and _, confirm that passes validation.""" + supervisor_mock_data = { + "version_latest": "1.0.0", + "version": "1.0.0", + "auto_update": True, + "addons": [ + { + "name": "test.a_1-2", + "slug": "test.a_1-2", + "state": "stopped", + "update_available": False, + "version": "1.0.0", + "version_latest": "1.0.0", + "repository": "core", + "icon": False, + }, + ], + } + with patch.dict(os.environ, MOCK_ENVIRON), patch( + "homeassistant.components.hassio.HassIO.is_connected", + return_value=None, + ), patch( + "homeassistant.components.hassio.HassIO.get_supervisor_info", + return_value=supervisor_mock_data, + ): + assert await async_setup_component(hass, "hassio", {}) + await hass.async_block_till_done() + + await hass.services.async_call("hassio", "addon_start", {"addon": "test.a_1-2"}) + + async def test_service_calls_core( hassio_env, hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: diff --git a/tests/components/homeassistant/test_init.py b/tests/components/homeassistant/test_init.py index 652fc4a1fdda78..4c5643ae3ca5bf 100644 --- a/tests/components/homeassistant/test_init.py +++ b/tests/components/homeassistant/test_init.py @@ -305,6 +305,8 @@ async def test_setting_location(hass: HomeAssistant) -> None: # Just to make sure that we are updating values. assert hass.config.latitude != 30 assert hass.config.longitude != 40 + elevation = hass.config.elevation + assert elevation != 50 await hass.services.async_call( "homeassistant", "set_location", @@ -314,6 +316,15 @@ async def test_setting_location(hass: HomeAssistant) -> None: assert len(events) == 1 assert hass.config.latitude == 30 assert hass.config.longitude == 40 + assert hass.config.elevation == elevation + + await hass.services.async_call( + "homeassistant", + "set_location", + {"latitude": 30, "longitude": 40, "elevation": 50}, + blocking=True, + ) + assert hass.config.elevation == 50 async def test_require_admin( diff --git a/tests/components/homeassistant_hardware/conftest.py b/tests/components/homeassistant_hardware/conftest.py index 60083c2de94421..02b468e558e348 100644 --- a/tests/components/homeassistant_hardware/conftest.py +++ b/tests/components/homeassistant_hardware/conftest.py @@ -23,7 +23,7 @@ def mock_probe(config: dict[str, Any]) -> None: with patch( "bellows.zigbee.application.ControllerApplication.probe", side_effect=mock_probe ), patch( - "homeassistant.components.zha.radio_manager.ZhaRadioManager._connect_zigpy_app", + "homeassistant.components.zha.radio_manager.ZhaRadioManager.connect_zigpy_app", return_value=mock_connect_app, ), patch( "homeassistant.components.zha.async_setup_entry", diff --git a/tests/components/homeassistant_sky_connect/conftest.py b/tests/components/homeassistant_sky_connect/conftest.py index 85017866db9c55..90dbe5af384769 100644 --- a/tests/components/homeassistant_sky_connect/conftest.py +++ b/tests/components/homeassistant_sky_connect/conftest.py @@ -25,7 +25,7 @@ def mock_zha(): ) with patch( - "homeassistant.components.zha.radio_manager.ZhaRadioManager._connect_zigpy_app", + "homeassistant.components.zha.radio_manager.ZhaRadioManager.connect_zigpy_app", return_value=mock_connect_app, ), patch( "homeassistant.components.zha.async_setup_entry", diff --git a/tests/components/homeassistant_sky_connect/test_init.py b/tests/components/homeassistant_sky_connect/test_init.py index cbf1cfa7d36b55..3afc8c24774fd4 100644 --- a/tests/components/homeassistant_sky_connect/test_init.py +++ b/tests/components/homeassistant_sky_connect/test_init.py @@ -45,7 +45,7 @@ def mock_probe(config: dict[str, Any]) -> None: with patch( "bellows.zigbee.application.ControllerApplication.probe", side_effect=mock_probe ), patch( - "homeassistant.components.zha.radio_manager.ZhaRadioManager._connect_zigpy_app", + "homeassistant.components.zha.radio_manager.ZhaRadioManager.connect_zigpy_app", return_value=mock_connect_app, ): yield diff --git a/tests/components/homeassistant_yellow/conftest.py b/tests/components/homeassistant_yellow/conftest.py index e4a666f9f04fc3..a7d66d659f01b9 100644 --- a/tests/components/homeassistant_yellow/conftest.py +++ b/tests/components/homeassistant_yellow/conftest.py @@ -23,7 +23,7 @@ def mock_probe(config: dict[str, Any]) -> None: with patch( "bellows.zigbee.application.ControllerApplication.probe", side_effect=mock_probe ), patch( - "homeassistant.components.zha.radio_manager.ZhaRadioManager._connect_zigpy_app", + "homeassistant.components.zha.radio_manager.ZhaRadioManager.connect_zigpy_app", return_value=mock_connect_app, ), patch( "homeassistant.components.zha.async_setup_entry", diff --git a/tests/components/homekit/test_type_cameras.py b/tests/components/homekit/test_type_cameras.py index 9fcd36d06f35c4..fdb092467f32b3 100644 --- a/tests/components/homekit/test_type_cameras.py +++ b/tests/components/homekit/test_type_cameras.py @@ -187,11 +187,11 @@ async def test_camera_stream_source_configured( "yuv420p -r 30 -b:v 299k -bufsize 1196k -maxrate 299k -payload_type 99 -ssrc {v_ssrc} -f " "rtp -srtp_out_suite AES_CM_128_HMAC_SHA1_80 -srtp_out_params " "zdPmNLWeI86DtLJHvVLI6YPvqhVeeiLsNtrAgbgL " - "srtp://192.168.208.5:51246?rtcpport=51246&localrtcpport=51246&pkt_size=1316 -map 0:a:0 " + "srtp://192.168.208.5:51246?rtcpport=51246&localrtpport=51246&pkt_size=1316 -map 0:a:0 " "-vn -c:a libopus -application lowdelay -ac 1 -ar 24k -b:a 24k -bufsize 96k -payload_type " "110 -ssrc {a_ssrc} -f rtp -srtp_out_suite AES_CM_128_HMAC_SHA1_80 -srtp_out_params " "shnETgfD+7xUQ8zRdsaytY11wu6CO73IJ+RZVJpU " - "srtp://192.168.208.5:51108?rtcpport=51108&localrtcpport=51108&pkt_size=188" + "srtp://192.168.208.5:51108?rtcpport=51108&localrtpport=51108&pkt_size=188" ) working_ffmpeg.open.assert_called_with( @@ -344,7 +344,7 @@ async def test_camera_stream_source_found( "yuv420p -r 30 -b:v 299k -bufsize 1196k -maxrate 299k -payload_type 99 -ssrc {v_ssrc} -f " "rtp -srtp_out_suite AES_CM_128_HMAC_SHA1_80 -srtp_out_params " "zdPmNLWeI86DtLJHvVLI6YPvqhVeeiLsNtrAgbgL " - "srtp://192.168.208.5:51246?rtcpport=51246&localrtcpport=51246&pkt_size=1316" + "srtp://192.168.208.5:51246?rtcpport=51246&localrtpport=51246&pkt_size=1316" ) working_ffmpeg.open.assert_called_with( @@ -507,11 +507,11 @@ async def test_camera_stream_source_configured_and_copy_codec( "-map 0:v:0 -an -c:v copy -tune zerolatency -pix_fmt yuv420p -r 30 -b:v 299k " "-bufsize 1196k -maxrate 299k -payload_type 99 -ssrc {v_ssrc} -f rtp -srtp_out_suite " "AES_CM_128_HMAC_SHA1_80 -srtp_out_params zdPmNLWeI86DtLJHvVLI6YPvqhVeeiLsNtrAgbgL " - "srtp://192.168.208.5:51246?rtcpport=51246&localrtcpport=51246&pkt_size=1316 -map 0:a:0 " + "srtp://192.168.208.5:51246?rtcpport=51246&localrtpport=51246&pkt_size=1316 -map 0:a:0 " "-vn -c:a copy -ac 1 -ar 24k -b:a 24k -bufsize 96k -payload_type 110 -ssrc {a_ssrc} " "-f rtp -srtp_out_suite AES_CM_128_HMAC_SHA1_80 -srtp_out_params " "shnETgfD+7xUQ8zRdsaytY11wu6CO73IJ+RZVJpU " - "srtp://192.168.208.5:51108?rtcpport=51108&localrtcpport=51108&pkt_size=188" + "srtp://192.168.208.5:51108?rtcpport=51108&localrtpport=51108&pkt_size=188" ) working_ffmpeg.open.assert_called_with( @@ -580,11 +580,11 @@ async def test_camera_stream_source_configured_and_override_profile_names( "-map 0:v:0 -an -c:v h264_v4l2m2m -profile:v 4 -tune zerolatency -pix_fmt yuv420p -r 30 -b:v 299k " "-bufsize 1196k -maxrate 299k -payload_type 99 -ssrc {v_ssrc} -f rtp -srtp_out_suite " "AES_CM_128_HMAC_SHA1_80 -srtp_out_params zdPmNLWeI86DtLJHvVLI6YPvqhVeeiLsNtrAgbgL " - "srtp://192.168.208.5:51246?rtcpport=51246&localrtcpport=51246&pkt_size=1316 -map 0:a:0 " + "srtp://192.168.208.5:51246?rtcpport=51246&localrtpport=51246&pkt_size=1316 -map 0:a:0 " "-vn -c:a copy -ac 1 -ar 24k -b:a 24k -bufsize 96k -payload_type 110 -ssrc {a_ssrc} " "-f rtp -srtp_out_suite AES_CM_128_HMAC_SHA1_80 -srtp_out_params " "shnETgfD+7xUQ8zRdsaytY11wu6CO73IJ+RZVJpU " - "srtp://192.168.208.5:51108?rtcpport=51108&localrtcpport=51108&pkt_size=188" + "srtp://192.168.208.5:51108?rtcpport=51108&localrtpport=51108&pkt_size=188" ) working_ffmpeg.open.assert_called_with( @@ -654,11 +654,11 @@ async def test_camera_streaming_fails_after_starting_ffmpeg( "-map 0:v:0 -an -c:v h264_omx -profile:v high -tune zerolatency -pix_fmt yuv420p -r 30 -b:v 299k " "-bufsize 1196k -maxrate 299k -payload_type 99 -ssrc {v_ssrc} -f rtp -srtp_out_suite " "AES_CM_128_HMAC_SHA1_80 -srtp_out_params zdPmNLWeI86DtLJHvVLI6YPvqhVeeiLsNtrAgbgL " - "srtp://192.168.208.5:51246?rtcpport=51246&localrtcpport=51246&pkt_size=1316 -map 0:a:0 " + "srtp://192.168.208.5:51246?rtcpport=51246&localrtpport=51246&pkt_size=1316 -map 0:a:0 " "-vn -c:a copy -ac 1 -ar 24k -b:a 24k -bufsize 96k -payload_type 110 -ssrc {a_ssrc} " "-f rtp -srtp_out_suite AES_CM_128_HMAC_SHA1_80 -srtp_out_params " "shnETgfD+7xUQ8zRdsaytY11wu6CO73IJ+RZVJpU " - "srtp://192.168.208.5:51108?rtcpport=51108&localrtcpport=51108&pkt_size=188" + "srtp://192.168.208.5:51108?rtcpport=51108&localrtpport=51108&pkt_size=188" ) ffmpeg_with_invalid_pid.open.assert_called_with( diff --git a/tests/components/homekit_controller/test_config_flow.py b/tests/components/homekit_controller/test_config_flow.py index c989bc01ff2606..3412e41aa175ce 100644 --- a/tests/components/homekit_controller/test_config_flow.py +++ b/tests/components/homekit_controller/test_config_flow.py @@ -1,5 +1,6 @@ """Tests for homekit_controller config flow.""" import asyncio +from ipaddress import ip_address import unittest.mock from unittest.mock import AsyncMock, patch @@ -174,10 +175,10 @@ def get_device_discovery_info( ) -> zeroconf.ZeroconfServiceInfo: """Turn a aiohomekit format zeroconf entry into a homeassistant one.""" result = zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname=device.description.name, name=device.description.name + "._hap._tcp.local.", - addresses=["127.0.0.1"], port=8080, properties={ "md": device.description.model, @@ -1179,3 +1180,80 @@ async def test_bluetooth_valid_device_discovery_unpaired( assert result3["data"] == {} assert storage.get_map("00:00:00:00:00:00") is not None + + +async def test_discovery_updates_ip_when_config_entry_set_up( + hass: HomeAssistant, controller +) -> None: + """Already configured updates ip when config entry set up.""" + entry = MockConfigEntry( + domain="homekit_controller", + data={ + "AccessoryIP": "4.4.4.4", + "AccessoryPort": 66, + "AccessoryPairingID": "AA:BB:CC:DD:EE:FF", + }, + unique_id="aa:bb:cc:dd:ee:ff", + ) + entry.add_to_hass(hass) + + connection_mock = AsyncMock() + hass.data[KNOWN_DEVICES] = {"AA:BB:CC:DD:EE:FF": connection_mock} + + device = setup_mock_accessory(controller) + discovery_info = get_device_discovery_info(device) + + # Set device as already paired + discovery_info.properties["sf"] = 0x00 + discovery_info.properties[zeroconf.ATTR_PROPERTIES_ID] = "Aa:bB:cC:dD:eE:fF" + + # Device is discovered + result = await hass.config_entries.flow.async_init( + "homekit_controller", + context={"source": config_entries.SOURCE_ZEROCONF}, + data=discovery_info, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + await hass.async_block_till_done() + + assert entry.data["AccessoryIP"] == discovery_info.host + assert entry.data["AccessoryPort"] == discovery_info.port + + +async def test_discovery_updates_ip_config_entry_not_set_up( + hass: HomeAssistant, controller +) -> None: + """Already configured updates ip when the config entry is not set up.""" + entry = MockConfigEntry( + domain="homekit_controller", + data={ + "AccessoryIP": "4.4.4.4", + "AccessoryPort": 66, + "AccessoryPairingID": "AA:BB:CC:DD:EE:FF", + }, + unique_id="aa:bb:cc:dd:ee:ff", + ) + entry.add_to_hass(hass) + + AsyncMock() + + device = setup_mock_accessory(controller) + discovery_info = get_device_discovery_info(device) + + # Set device as already paired + discovery_info.properties["sf"] = 0x00 + discovery_info.properties[zeroconf.ATTR_PROPERTIES_ID] = "Aa:bB:cC:dD:eE:fF" + + # Device is discovered + result = await hass.config_entries.flow.async_init( + "homekit_controller", + context={"source": config_entries.SOURCE_ZEROCONF}, + data=discovery_info, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + await hass.async_block_till_done() + + assert entry.data["AccessoryIP"] == discovery_info.host + assert entry.data["AccessoryPort"] == discovery_info.port diff --git a/tests/components/homewizard/test_config_flow.py b/tests/components/homewizard/test_config_flow.py index 7a1652549d79ba..7c6fb0bdb0d7f8 100644 --- a/tests/components/homewizard/test_config_flow.py +++ b/tests/components/homewizard/test_config_flow.py @@ -1,4 +1,5 @@ """Test the homewizard config flow.""" +from ipaddress import ip_address from unittest.mock import MagicMock, patch from homewizard_energy.errors import DisabledError, RequestError, UnsupportedError @@ -58,8 +59,8 @@ async def test_discovery_flow_works( """Test discovery setup flow works.""" service_info = zeroconf.ZeroconfServiceInfo( - host="192.168.43.183", - addresses=["192.168.43.183"], + ip_address=ip_address("192.168.43.183"), + ip_addresses=[ip_address("192.168.43.183")], port=80, hostname="p1meter-ddeeff.local.", type="", @@ -131,8 +132,8 @@ async def test_discovery_flow_during_onboarding( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.43.183", - addresses=["192.168.43.183"], + ip_address=ip_address("192.168.43.183"), + ip_addresses=[ip_address("192.168.43.183")], port=80, hostname="p1meter-ddeeff.local.", type="mock_type", @@ -177,8 +178,8 @@ def mock_initialize(): DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.43.183", - addresses=["192.168.43.183"], + ip_address=ip_address("192.168.43.183"), + ip_addresses=[ip_address("192.168.43.183")], port=80, hostname="p1meter-ddeeff.local.", type="mock_type", @@ -229,8 +230,8 @@ async def test_discovery_disabled_api( """Test discovery detecting disabled api.""" service_info = zeroconf.ZeroconfServiceInfo( - host="192.168.43.183", - addresses=["192.168.43.183"], + ip_address=ip_address("192.168.43.183"), + ip_addresses=[ip_address("192.168.43.183")], port=80, hostname="p1meter-ddeeff.local.", type="", @@ -279,8 +280,8 @@ async def test_discovery_missing_data_in_service_info( """Test discovery detecting missing discovery info.""" service_info = zeroconf.ZeroconfServiceInfo( - host="192.168.43.183", - addresses=["192.168.43.183"], + ip_address=ip_address("192.168.43.183"), + ip_addresses=[ip_address("192.168.43.183")], port=80, hostname="p1meter-ddeeff.local.", type="", @@ -310,8 +311,8 @@ async def test_discovery_invalid_api( """Test discovery detecting invalid_api.""" service_info = zeroconf.ZeroconfServiceInfo( - host="192.168.43.183", - addresses=["192.168.43.183"], + ip_address=ip_address("192.168.43.183"), + ip_addresses=[ip_address("192.168.43.183")], port=80, hostname="p1meter-ddeeff.local.", type="", diff --git a/tests/components/honeywell/conftest.py b/tests/components/honeywell/conftest.py index 8406d76803a330..876050586d27b8 100644 --- a/tests/components/honeywell/conftest.py +++ b/tests/components/honeywell/conftest.py @@ -108,6 +108,8 @@ def device(): mock_device.heat_away_temp = HEATAWAY mock_device.cool_away_temp = COOLAWAY + mock_device.raw_dr_data = {"CoolSetpLimit": None, "HeatSetpLimit": None} + return mock_device @@ -127,6 +129,27 @@ def device_with_outdoor_sensor(): mock_device.temperature_unit = "C" mock_device.outdoor_temperature = OUTDOORTEMP mock_device.outdoor_humidity = OUTDOORHUMIDITY + mock_device.raw_ui_data = { + "SwitchOffAllowed": True, + "SwitchAutoAllowed": True, + "SwitchCoolAllowed": True, + "SwitchHeatAllowed": True, + "SwitchEmergencyHeatAllowed": True, + "HeatUpperSetptLimit": HEATUPPERSETPOINTLIMIT, + "HeatLowerSetptLimit": HEATLOWERSETPOINTLIMIT, + "CoolUpperSetptLimit": COOLUPPERSETPOINTLIMIT, + "CoolLowerSetptLimit": COOLLOWERSETPOINTLIMIT, + "HeatNextPeriod": NEXTHEATPERIOD, + "CoolNextPeriod": NEXTCOOLPERIOD, + } + mock_device.raw_fan_data = { + "fanModeOnAllowed": True, + "fanModeAutoAllowed": True, + "fanModeCirculateAllowed": True, + } + + mock_device.raw_dr_data = {"CoolSetpLimit": None, "HeatSetpLimit": None} + return mock_device @@ -145,6 +168,26 @@ def another_device(): mock_device.mac_address = "macaddress1" mock_device.outdoor_temperature = None mock_device.outdoor_humidity = None + mock_device.raw_ui_data = { + "SwitchOffAllowed": True, + "SwitchAutoAllowed": True, + "SwitchCoolAllowed": True, + "SwitchHeatAllowed": True, + "SwitchEmergencyHeatAllowed": True, + "HeatUpperSetptLimit": HEATUPPERSETPOINTLIMIT, + "HeatLowerSetptLimit": HEATLOWERSETPOINTLIMIT, + "CoolUpperSetptLimit": COOLUPPERSETPOINTLIMIT, + "CoolLowerSetptLimit": COOLLOWERSETPOINTLIMIT, + "HeatNextPeriod": NEXTHEATPERIOD, + "CoolNextPeriod": NEXTCOOLPERIOD, + } + mock_device.raw_fan_data = { + "fanModeOnAllowed": True, + "fanModeAutoAllowed": True, + "fanModeCirculateAllowed": True, + } + + mock_device.raw_dr_data = {"CoolSetpLimit": None, "HeatSetpLimit": None} return mock_device diff --git a/tests/components/honeywell/snapshots/test_diagnostics.ambr b/tests/components/honeywell/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000000..3077fc747deb10 --- /dev/null +++ b/tests/components/honeywell/snapshots/test_diagnostics.ambr @@ -0,0 +1,53 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'Device 1234567': dict({ + 'DR Data': dict({ + 'CoolSetpLimit': None, + 'HeatSetpLimit': None, + }), + 'Fan Data': dict({ + 'fanModeAutoAllowed': True, + 'fanModeCirculateAllowed': True, + 'fanModeOnAllowed': True, + }), + 'UI Data': dict({ + 'CoolLowerSetptLimit': 10, + 'CoolNextPeriod': 10, + 'CoolUpperSetptLimit': 20, + 'HeatLowerSetptLimit': 20, + 'HeatNextPeriod': 10, + 'HeatUpperSetptLimit': 35, + 'SwitchAutoAllowed': True, + 'SwitchCoolAllowed': True, + 'SwitchEmergencyHeatAllowed': True, + 'SwitchHeatAllowed': True, + 'SwitchOffAllowed': True, + }), + }), + 'Device 7654321': dict({ + 'DR Data': dict({ + 'CoolSetpLimit': None, + 'HeatSetpLimit': None, + }), + 'Fan Data': dict({ + 'fanModeAutoAllowed': True, + 'fanModeCirculateAllowed': True, + 'fanModeOnAllowed': True, + }), + 'UI Data': dict({ + 'CoolLowerSetptLimit': 10, + 'CoolNextPeriod': 10, + 'CoolUpperSetptLimit': 20, + 'HeatLowerSetptLimit': 20, + 'HeatNextPeriod': 10, + 'HeatUpperSetptLimit': 35, + 'SwitchAutoAllowed': True, + 'SwitchCoolAllowed': True, + 'SwitchEmergencyHeatAllowed': True, + 'SwitchHeatAllowed': True, + 'SwitchOffAllowed': True, + }), + }), + }) +# --- diff --git a/tests/components/honeywell/test_climate.py b/tests/components/honeywell/test_climate.py index 92caa29b71f331..7bd76cb8522f94 100644 --- a/tests/components/honeywell/test_climate.py +++ b/tests/components/honeywell/test_climate.py @@ -37,6 +37,7 @@ STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.util.dt import utcnow @@ -193,6 +194,15 @@ async def test_mode_service_calls( device.set_system_mode.assert_called_once_with("auto") device.set_system_mode.reset_mock() + device.set_system_mode.side_effect = aiosomecomfort.SomeComfortError + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_HVAC_MODE: HVACMode.HEAT_COOL}, + blocking=True, + ) + device.set_system_mode.assert_called_once_with("auto") async def test_auxheat_service_calls( @@ -211,6 +221,7 @@ async def test_auxheat_service_calls( device.set_system_mode.assert_called_once_with("emheat") device.set_system_mode.reset_mock() + await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_AUX_HEAT, @@ -219,6 +230,27 @@ async def test_auxheat_service_calls( ) device.set_system_mode.assert_called_once_with("heat") + device.set_system_mode.reset_mock() + device.set_system_mode.side_effect = aiosomecomfort.SomeComfortError + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_AUX_HEAT, + {ATTR_ENTITY_ID: entity_id, ATTR_AUX_HEAT: True}, + blocking=True, + ) + device.set_system_mode.assert_called_once_with("emheat") + + device.set_system_mode.reset_mock() + device.set_system_mode.side_effect = aiosomecomfort.SomeComfortError + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_AUX_HEAT, + {ATTR_ENTITY_ID: entity_id, ATTR_AUX_HEAT: False}, + blocking=True, + ) + async def test_fan_modes_service_calls( hass: HomeAssistant, device: MagicMock, config_entry: MagicMock @@ -256,6 +288,17 @@ async def test_fan_modes_service_calls( device.set_fan_mode.assert_called_once_with("circulate") + device.set_fan_mode.reset_mock() + + device.set_fan_mode.side_effect = aiosomecomfort.SomeComfortError + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_FAN_MODE: FAN_DIFFUSE}, + blocking=True, + ) + async def test_service_calls_off_mode( hass: HomeAssistant, @@ -299,16 +342,18 @@ async def test_service_calls_off_mode( device.set_setpoint_heat.reset_mock() device.set_setpoint_heat.side_effect = aiosomecomfort.SomeComfortError caplog.clear() - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_TEMPERATURE, - { - ATTR_ENTITY_ID: entity_id, - ATTR_TARGET_TEMP_LOW: 25.0, - ATTR_TARGET_TEMP_HIGH: 35.0, - }, - blocking=True, - ) + + with pytest.raises(ValueError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_TARGET_TEMP_LOW: 25.0, + ATTR_TARGET_TEMP_HIGH: 35.0, + }, + blocking=True, + ) device.set_setpoint_cool.assert_called_with(95) device.set_setpoint_heat.assert_called_with(77) assert "Invalid temperature" in caplog.text @@ -387,7 +432,6 @@ async def test_service_calls_off_mode( device.set_hold_heat.side_effect = aiosomecomfort.SomeComfortError device.raw_ui_data["StatusHeat"] = 2 device.raw_ui_data["StatusCool"] = 2 - await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_PRESET_MODE, @@ -443,16 +487,18 @@ async def test_service_calls_cool_mode( caplog.clear() device.set_setpoint_cool.reset_mock() device.set_setpoint_cool.side_effect = aiosomecomfort.SomeComfortError - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_TEMPERATURE, - { - ATTR_ENTITY_ID: entity_id, - ATTR_TARGET_TEMP_LOW: 25.0, - ATTR_TARGET_TEMP_HIGH: 35.0, - }, - blocking=True, - ) + + with pytest.raises(ValueError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_TARGET_TEMP_LOW: 25.0, + ATTR_TARGET_TEMP_HIGH: 35.0, + }, + blocking=True, + ) device.set_setpoint_cool.assert_called_with(95) device.set_setpoint_heat.assert_called_with(77) assert "Invalid temperature" in caplog.text @@ -474,12 +520,13 @@ async def test_service_calls_cool_mode( device.set_hold_cool.side_effect = aiosomecomfort.SomeComfortError caplog.clear() - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_AWAY}, - blocking=True, - ) + with pytest.raises(ValueError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_AWAY}, + blocking=True, + ) device.set_hold_cool.assert_called_once_with(True, 12) device.set_hold_heat.assert_not_called() @@ -491,12 +538,13 @@ async def test_service_calls_cool_mode( device.raw_ui_data["StatusHeat"] = 2 device.raw_ui_data["StatusCool"] = 2 - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, - blocking=True, - ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, + blocking=True, + ) device.set_hold_cool.assert_called_once_with(True) device.set_hold_heat.assert_not_called() @@ -504,12 +552,13 @@ async def test_service_calls_cool_mode( device.hold_heat = True device.hold_cool = True - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_TEMPERATURE, - {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: "20"}, - blocking=True, - ) + with pytest.raises(ValueError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: "20"}, + blocking=True, + ) device.set_setpoint_cool.assert_called_once() @@ -519,25 +568,25 @@ async def test_service_calls_cool_mode( device.raw_ui_data["StatusHeat"] = 2 device.raw_ui_data["StatusCool"] = 2 caplog.clear() - - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, - blocking=True, - ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, + blocking=True, + ) device.set_hold_cool.assert_called_once_with(True) device.set_hold_heat.assert_not_called() assert "Couldn't set permanent hold" in caplog.text reset_mock(device) - - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_NONE}, - blocking=True, - ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_NONE}, + blocking=True, + ) device.set_hold_heat.assert_not_called() device.set_hold_cool.assert_called_once_with(False) @@ -546,13 +595,13 @@ async def test_service_calls_cool_mode( caplog.clear() device.set_hold_cool.side_effect = aiosomecomfort.SomeComfortError - - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_NONE}, - blocking=True, - ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_NONE}, + blocking=True, + ) device.set_hold_heat.assert_not_called() device.set_hold_cool.assert_called_once_with(False) @@ -563,12 +612,13 @@ async def test_service_calls_cool_mode( device.raw_ui_data["StatusHeat"] = 2 device.raw_ui_data["StatusCool"] = 2 - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, - blocking=True, - ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, + blocking=True, + ) device.set_hold_cool.assert_called_once_with(True) device.set_hold_heat.assert_not_called() @@ -580,13 +630,13 @@ async def test_service_calls_cool_mode( device.raw_ui_data["StatusHeat"] = 2 device.raw_ui_data["StatusCool"] = 2 - - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, - blocking=True, - ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, + blocking=True, + ) device.set_hold_cool.assert_called_once_with(True) device.set_hold_heat.assert_not_called() @@ -599,12 +649,13 @@ async def test_service_calls_cool_mode( device.raw_ui_data["StatusCool"] = 2 device.system_mode = "Junk" - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, - blocking=True, - ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, + blocking=True, + ) device.set_hold_cool.assert_not_called() device.set_hold_heat.assert_not_called() @@ -640,13 +691,13 @@ async def test_service_calls_heat_mode( device.set_hold_heat.reset_mock() device.set_hold_heat.side_effect = aiosomecomfort.SomeComfortError - - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_TEMPERATURE, - {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: 15}, - blocking=True, - ) + with pytest.raises(ValueError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: 15}, + blocking=True, + ) device.set_hold_heat.assert_called_once_with(datetime.time(2, 30), 59) device.set_hold_heat.reset_mock() assert "Invalid temperature" in caplog.text @@ -667,16 +718,17 @@ async def test_service_calls_heat_mode( device.set_setpoint_heat.reset_mock() device.set_setpoint_heat.side_effect = aiosomecomfort.SomeComfortError - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_TEMPERATURE, - { - ATTR_ENTITY_ID: entity_id, - ATTR_TARGET_TEMP_LOW: 25.0, - ATTR_TARGET_TEMP_HIGH: 35.0, - }, - blocking=True, - ) + with pytest.raises(ValueError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_TARGET_TEMP_LOW: 25.0, + ATTR_TARGET_TEMP_HIGH: 35.0, + }, + blocking=True, + ) device.set_setpoint_cool.assert_called_with(95) device.set_setpoint_heat.assert_called_with(77) assert "Invalid temperature" in caplog.text @@ -685,12 +737,13 @@ async def test_service_calls_heat_mode( device.raw_ui_data["StatusHeat"] = 2 device.raw_ui_data["StatusCool"] = 2 - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, - blocking=True, - ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, + blocking=True, + ) device.set_hold_heat.assert_called_once_with(True) device.set_hold_cool.assert_not_called() @@ -698,12 +751,13 @@ async def test_service_calls_heat_mode( device.hold_heat = True device.hold_cool = True - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_TEMPERATURE, - {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: "20"}, - blocking=True, - ) + with pytest.raises(ValueError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: "20"}, + blocking=True, + ) device.set_setpoint_heat.assert_called_once() @@ -715,24 +769,26 @@ async def test_service_calls_heat_mode( device.raw_ui_data["StatusHeat"] = 2 device.raw_ui_data["StatusCool"] = 2 - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, - blocking=True, - ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, + blocking=True, + ) device.set_hold_heat.assert_called_once_with(True) device.set_hold_cool.assert_not_called() assert "Couldn't set permanent hold" in caplog.text reset_mock(device) - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_AWAY}, - blocking=True, - ) + with pytest.raises(ValueError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_AWAY}, + blocking=True, + ) device.set_hold_heat.assert_called_once_with(True, 22) device.set_hold_cool.assert_not_called() @@ -743,12 +799,13 @@ async def test_service_calls_heat_mode( device.set_hold_heat.side_effect = aiosomecomfort.SomeComfortError - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_AWAY}, - blocking=True, - ) + with pytest.raises(ValueError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_AWAY}, + blocking=True, + ) device.set_hold_heat.assert_called_once_with(True, 22) device.set_hold_cool.assert_not_called() @@ -757,13 +814,13 @@ async def test_service_calls_heat_mode( reset_mock(device) caplog.clear() - - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_NONE}, - blocking=True, - ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_NONE}, + blocking=True, + ) device.set_hold_heat.assert_called_once_with(False) device.set_hold_cool.assert_called_once_with(False) @@ -771,13 +828,13 @@ async def test_service_calls_heat_mode( device.set_hold_heat.reset_mock() device.set_hold_cool.reset_mock() device.set_hold_heat.side_effect = aiosomecomfort.SomeComfortError - - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_NONE}, - blocking=True, - ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_NONE}, + blocking=True, + ) device.set_hold_heat.assert_called_once_with(False) assert "Can not stop hold mode" in caplog.text @@ -786,12 +843,13 @@ async def test_service_calls_heat_mode( device.raw_ui_data["StatusHeat"] = 2 device.raw_ui_data["StatusCool"] = 2 - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, - blocking=True, - ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, + blocking=True, + ) device.set_hold_heat.assert_called_once_with(True) device.set_hold_cool.assert_not_called() @@ -802,12 +860,13 @@ async def test_service_calls_heat_mode( device.raw_ui_data["StatusHeat"] = 2 device.raw_ui_data["StatusCool"] = 2 - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, - blocking=True, - ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, + blocking=True, + ) device.set_hold_heat.assert_called_once_with(True) device.set_hold_cool.assert_not_called() @@ -863,13 +922,13 @@ async def test_service_calls_auto_mode( device.set_hold_cool.side_effect = aiosomecomfort.SomeComfortError device.set_hold_heat.side_effect = aiosomecomfort.SomeComfortError - - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_TEMPERATURE, - {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: 15}, - blocking=True, - ) + with pytest.raises(ValueError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: 15}, + blocking=True, + ) device.set_setpoint_heat.assert_not_called() assert "Invalid temperature" in caplog.text @@ -878,16 +937,17 @@ async def test_service_calls_auto_mode( device.set_setpoint_heat.side_effect = aiosomecomfort.SomeComfortError device.set_setpoint_cool.side_effect = aiosomecomfort.SomeComfortError - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_TEMPERATURE, - { - ATTR_ENTITY_ID: entity_id, - ATTR_TARGET_TEMP_LOW: 25.0, - ATTR_TARGET_TEMP_HIGH: 35.0, - }, - blocking=True, - ) + with pytest.raises(ValueError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_TARGET_TEMP_LOW: 25.0, + ATTR_TARGET_TEMP_HIGH: 35.0, + }, + blocking=True, + ) device.set_setpoint_heat.assert_not_called() assert "Invalid temperature" in caplog.text @@ -917,12 +977,13 @@ async def test_service_calls_auto_mode( device.raw_ui_data["StatusHeat"] = 2 device.raw_ui_data["StatusCool"] = 2 - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, - blocking=True, - ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, + blocking=True, + ) device.set_hold_cool.assert_called_once_with(True) device.set_hold_heat.assert_called_once_with(True) assert "Couldn't set permanent hold" in caplog.text @@ -931,12 +992,13 @@ async def test_service_calls_auto_mode( device.set_setpoint_heat.side_effect = None device.set_setpoint_cool.side_effect = None - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_AWAY}, - blocking=True, - ) + with pytest.raises(ValueError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_AWAY}, + blocking=True, + ) device.set_hold_cool.assert_called_once_with(True, 12) device.set_hold_heat.assert_called_once_with(True, 22) @@ -944,25 +1006,26 @@ async def test_service_calls_auto_mode( reset_mock(device) caplog.clear() - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_NONE}, - blocking=True, - ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_NONE}, + blocking=True, + ) device.set_hold_heat.assert_called_once_with(False) device.set_hold_cool.assert_called_once_with(False) reset_mock(device) device.set_hold_cool.side_effect = aiosomecomfort.SomeComfortError - - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_NONE}, - blocking=True, - ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_NONE}, + blocking=True, + ) device.set_hold_heat.assert_not_called() device.set_hold_cool.assert_called_once_with(False) @@ -974,12 +1037,13 @@ async def test_service_calls_auto_mode( device.raw_ui_data["StatusHeat"] = 2 device.raw_ui_data["StatusCool"] = 2 - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, - blocking=True, - ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, + blocking=True, + ) device.set_hold_cool.assert_called_once_with(True) device.set_hold_heat.assert_not_called() @@ -990,12 +1054,13 @@ async def test_service_calls_auto_mode( device.raw_ui_data["StatusHeat"] = 2 device.raw_ui_data["StatusCool"] = 2 - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, - blocking=True, - ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, + blocking=True, + ) device.set_hold_cool.assert_called_once_with(True) device.set_hold_heat.assert_not_called() diff --git a/tests/components/honeywell/test_diagnostics.py b/tests/components/honeywell/test_diagnostics.py new file mode 100644 index 00000000000000..aafc50d5545dca --- /dev/null +++ b/tests/components/honeywell/test_diagnostics.py @@ -0,0 +1,35 @@ +"""Test Honeywell diagnostics.""" +from unittest.mock import MagicMock + +from syrupy import SnapshotAssertion + +from homeassistant.config_entries import ConfigEntryState +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 + +YAML_CONFIG = {"username": "test-user", "password": "test-password"} + + +async def test_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, + config_entry: MockConfigEntry, + location: MagicMock, + another_device: MagicMock, +) -> None: + """Test config entry diagnostics for Honeywell.""" + + location.devices_by_id[another_device.deviceid] = another_device + 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 hass.states.async_entity_ids_count() == 6 + + result = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + + assert result == snapshot diff --git a/tests/components/honeywell/test_init.py b/tests/components/honeywell/test_init.py index f7629fa958e887..73dda8ed223703 100644 --- a/tests/components/honeywell/test_init.py +++ b/tests/components/honeywell/test_init.py @@ -12,6 +12,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from . import init_integration @@ -33,7 +34,10 @@ async def test_setup_entry(hass: HomeAssistant, config_entry: MockConfigEntry) - async def test_setup_multiple_thermostats( - hass: HomeAssistant, config_entry: MockConfigEntry, location, another_device + hass: HomeAssistant, + config_entry: MockConfigEntry, + location: MagicMock, + another_device: MagicMock, ) -> None: """Test that the config form is shown.""" location.devices_by_id[another_device.deviceid] = another_device @@ -50,8 +54,8 @@ async def test_setup_multiple_thermostats_with_same_deviceid( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, config_entry: MockConfigEntry, - device, - client, + device: MagicMock, + client: MagicMock, ) -> None: """Test Honeywell TCC API returning duplicate device IDs.""" mock_location2 = create_autospec(aiosomecomfort.Location, instance=True) @@ -115,3 +119,62 @@ async def test_no_devices( client.locations_by_id = {} await init_integration(hass, config_entry) assert config_entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_remove_stale_device( + hass: HomeAssistant, + config_entry: MockConfigEntry, + location: MagicMock, + another_device: MagicMock, + client: MagicMock, +) -> None: + """Test that the stale device is removed.""" + location.devices_by_id[another_device.deviceid] = another_device + + config_entry.add_to_hass(hass) + + device_registry = dr.async_get(hass) + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("OtherDomain", 7654321)}, + ) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + assert ( + hass.states.async_entity_ids_count() == 6 + ) # 2 climate entities; 4 sensor entities + + device_registry = dr.async_get(hass) + device_entry = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + assert len(device_entry) == 3 + assert any((DOMAIN, 1234567) in device.identifiers for device in device_entry) + assert any((DOMAIN, 7654321) in device.identifiers for device in device_entry) + assert any( + ("OtherDomain", 7654321) in device.identifiers for device in device_entry + ) + + assert await config_entry.async_unload(hass) + await hass.async_block_till_done() + assert config_entry.state == ConfigEntryState.NOT_LOADED + + del location.devices_by_id[another_device.deviceid] + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + assert ( + hass.states.async_entity_ids_count() == 3 + ) # 1 climate entities; 2 sensor entities + + device_entry = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + assert len(device_entry) == 2 + assert any((DOMAIN, 1234567) in device.identifiers for device in device_entry) + assert any( + ("OtherDomain", 7654321) in device.identifiers for device in device_entry + ) diff --git a/tests/components/hue/test_config_flow.py b/tests/components/hue/test_config_flow.py index 6fa03e1de139ea..29b94b17da1ac7 100644 --- a/tests/components/hue/test_config_flow.py +++ b/tests/components/hue/test_config_flow.py @@ -1,5 +1,6 @@ """Tests for Philips Hue config flow.""" import asyncio +from ipaddress import ip_address from unittest.mock import Mock, patch from aiohue.discovery import URL_NUPNP @@ -416,8 +417,8 @@ async def test_bridge_homekit( const.DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, data=zeroconf.ZeroconfServiceInfo( - host="0.0.0.0", - addresses=["0.0.0.0"], + ip_address=ip_address("0.0.0.0"), + ip_addresses=[ip_address("0.0.0.0")], hostname="mock_hostname", name="mock_name", port=None, @@ -466,8 +467,8 @@ async def test_bridge_homekit_already_configured( const.DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, data=zeroconf.ZeroconfServiceInfo( - host="0.0.0.0", - addresses=["0.0.0.0"], + ip_address=ip_address("0.0.0.0"), + ip_addresses=[ip_address("0.0.0.0")], hostname="mock_hostname", name="mock_name", port=None, @@ -568,8 +569,8 @@ async def test_bridge_zeroconf( const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.217", - addresses=["192.168.1.217"], + ip_address=ip_address("192.168.1.217"), + ip_addresses=[ip_address("192.168.1.217")], port=443, hostname="Philips-hue.local", type="_hue._tcp.local.", @@ -604,8 +605,8 @@ async def test_bridge_zeroconf_already_exists( const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.217", - addresses=["192.168.1.217"], + ip_address=ip_address("192.168.1.217"), + ip_addresses=[ip_address("192.168.1.217")], port=443, hostname="Philips-hue.local", type="_hue._tcp.local.", @@ -629,8 +630,8 @@ async def test_bridge_zeroconf_ipv6(hass: HomeAssistant) -> None: const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="fd00::eeb5:faff:fe84:b17d", - addresses=["fd00::eeb5:faff:fe84:b17d"], + ip_address=ip_address("fd00::eeb5:faff:fe84:b17d"), + ip_addresses=[ip_address("fd00::eeb5:faff:fe84:b17d")], port=443, hostname="Philips-hue.local", type="_hue._tcp.local.", @@ -677,8 +678,8 @@ async def test_bridge_connection_failed( const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="blah", - addresses=["1.2.3.4"], + ip_address=ip_address("1.2.3.4"), + ip_addresses=[ip_address("1.2.3.4")], port=443, hostname="Philips-hue.local", type="_hue._tcp.local.", @@ -698,8 +699,8 @@ async def test_bridge_connection_failed( const.DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, data=zeroconf.ZeroconfServiceInfo( - host="0.0.0.0", - addresses=["0.0.0.0"], + ip_address=ip_address("0.0.0.0"), + ip_addresses=[ip_address("0.0.0.0")], hostname="mock_hostname", name="mock_name", port=None, diff --git a/tests/components/hunterdouglas_powerview/test_config_flow.py b/tests/components/hunterdouglas_powerview/test_config_flow.py index 943de66baacd46..f39b4c1f68e568 100644 --- a/tests/components/hunterdouglas_powerview/test_config_flow.py +++ b/tests/components/hunterdouglas_powerview/test_config_flow.py @@ -1,5 +1,6 @@ """Test the Logitech Harmony Hub config flow.""" import asyncio +from ipaddress import ip_address import json from unittest.mock import AsyncMock, MagicMock, patch @@ -12,9 +13,10 @@ from tests.common import MockConfigEntry, load_fixture +ZEROCONF_HOST = "1.2.3.4" HOMEKIT_DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( - host="1.2.3.4", - addresses=["1.2.3.4"], + ip_address=ip_address(ZEROCONF_HOST), + ip_addresses=[ip_address(ZEROCONF_HOST)], hostname="mock_hostname", name="Hunter Douglas Powerview Hub._hap._tcp.local.", port=None, @@ -23,8 +25,8 @@ ) ZEROCONF_DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( - host="1.2.3.4", - addresses=["1.2.3.4"], + ip_address=ip_address(ZEROCONF_HOST), + ip_addresses=[ip_address(ZEROCONF_HOST)], hostname="mock_hostname", name="Hunter Douglas Powerview Hub._powerview._tcp.local.", port=None, diff --git a/tests/components/idasen_desk/__init__.py b/tests/components/idasen_desk/__init__.py new file mode 100644 index 00000000000000..7e8becc4689c00 --- /dev/null +++ b/tests/components/idasen_desk/__init__.py @@ -0,0 +1,51 @@ +"""Tests for the IKEA Idasen Desk integration.""" + +from homeassistant.components.bluetooth import BluetoothServiceInfoBleak +from homeassistant.components.idasen_desk.const import DOMAIN +from homeassistant.const import CONF_ADDRESS +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.bluetooth import generate_advertisement_data, generate_ble_device + +IDASEN_DISCOVERY_INFO = BluetoothServiceInfoBleak( + name="Desk 1234", + address="AA:BB:CC:DD:EE:FF", + rssi=-60, + manufacturer_data={}, + service_uuids=["99fa0001-338a-1024-8a49-009c0215f78a"], + service_data={}, + source="local", + device=generate_ble_device(address="AA:BB:CC:DD:EE:FF", name="Desk 1234"), + advertisement=generate_advertisement_data(), + time=0, + connectable=True, +) + +NOT_IDASEN_DISCOVERY_INFO = BluetoothServiceInfoBleak( + name="Not Desk", + address="AA:BB:CC:DD:EE:FF", + rssi=-60, + manufacturer_data={}, + service_uuids=[], + service_data={}, + source="local", + device=generate_ble_device(address="AA:BB:CC:DD:EE:FF", name="Not Desk"), + advertisement=generate_advertisement_data(), + time=0, + connectable=True, +) + + +async def init_integration(hass: HomeAssistant) -> MockConfigEntry: + """Set up the IKEA Idasen Desk integration in Home Assistant.""" + entry = MockConfigEntry( + title="Test", + domain=DOMAIN, + data={CONF_ADDRESS: "AA:BB:CC:DD:EE:FF"}, + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + return entry diff --git a/tests/components/idasen_desk/conftest.py b/tests/components/idasen_desk/conftest.py new file mode 100644 index 00000000000000..736bc6346ceaa8 --- /dev/null +++ b/tests/components/idasen_desk/conftest.py @@ -0,0 +1,49 @@ +"""IKEA Idasen Desk fixtures.""" + +from collections.abc import Callable +from unittest import mock +from unittest.mock import AsyncMock, MagicMock + +import pytest + + +@pytest.fixture(autouse=True) +def mock_bluetooth(enable_bluetooth): + """Auto mock bluetooth.""" + + +@pytest.fixture(autouse=False) +def mock_desk_api(): + """Set up idasen desk API fixture.""" + with mock.patch("homeassistant.components.idasen_desk.Desk") as desk_patched: + mock_desk = MagicMock() + + def mock_init(update_callback: Callable[[int | None], None] | None): + mock_desk.trigger_update_callback = update_callback + return mock_desk + + desk_patched.side_effect = mock_init + + async def mock_connect(ble_device, monitor_height: bool = True): + mock_desk.is_connected = True + + async def mock_move_to(height: float): + mock_desk.height_percent = height + mock_desk.trigger_update_callback(height) + + async def mock_move_up(): + await mock_move_to(100) + + async def mock_move_down(): + await mock_move_to(0) + + mock_desk.connect = AsyncMock(side_effect=mock_connect) + mock_desk.disconnect = AsyncMock() + mock_desk.move_to = AsyncMock(side_effect=mock_move_to) + mock_desk.move_up = AsyncMock(side_effect=mock_move_up) + mock_desk.move_down = AsyncMock(side_effect=mock_move_down) + mock_desk.stop = AsyncMock() + mock_desk.height_percent = 60 + mock_desk.is_moving = False + + yield mock_desk diff --git a/tests/components/idasen_desk/test_config_flow.py b/tests/components/idasen_desk/test_config_flow.py new file mode 100644 index 00000000000000..8635e5bfddcdb5 --- /dev/null +++ b/tests/components/idasen_desk/test_config_flow.py @@ -0,0 +1,230 @@ +"""Test the IKEA Idasen Desk config flow.""" +from unittest.mock import patch + +from bleak import BleakError +import pytest + +from homeassistant import config_entries +from homeassistant.components.idasen_desk.const import DOMAIN +from homeassistant.const import CONF_ADDRESS +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import IDASEN_DISCOVERY_INFO, NOT_IDASEN_DISCOVERY_INFO + +from tests.common import MockConfigEntry + + +async def test_user_step_success(hass: HomeAssistant) -> None: + """Test user step success path.""" + with patch( + "homeassistant.components.idasen_desk.config_flow.async_discovered_service_info", + return_value=[NOT_IDASEN_DISCOVERY_INFO, IDASEN_DISCOVERY_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + with patch("homeassistant.components.idasen_desk.config_flow.Desk.connect"), patch( + "homeassistant.components.idasen_desk.config_flow.Desk.disconnect" + ), patch( + "homeassistant.components.idasen_desk.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_ADDRESS: IDASEN_DISCOVERY_INFO.address, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == IDASEN_DISCOVERY_INFO.name + assert result2["data"] == { + CONF_ADDRESS: IDASEN_DISCOVERY_INFO.address, + } + assert result2["result"].unique_id == IDASEN_DISCOVERY_INFO.address + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_user_step_no_devices_found(hass: HomeAssistant) -> None: + """Test user step with no devices found.""" + with patch( + "homeassistant.components.idasen_desk.config_flow.async_discovered_service_info", + return_value=[NOT_IDASEN_DISCOVERY_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + + +async def test_user_step_no_new_devices_found(hass: HomeAssistant) -> None: + """Test user step with only existing devices found.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_ADDRESS: IDASEN_DISCOVERY_INFO.address, + }, + unique_id=IDASEN_DISCOVERY_INFO.address, + ) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.idasen_desk.config_flow.async_discovered_service_info", + return_value=[IDASEN_DISCOVERY_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + + +@pytest.mark.parametrize("exception", [TimeoutError(), BleakError()]) +async def test_user_step_cannot_connect( + hass: HomeAssistant, exception: Exception +) -> None: + """Test user step and we cannot connect.""" + with patch( + "homeassistant.components.idasen_desk.config_flow.async_discovered_service_info", + return_value=[IDASEN_DISCOVERY_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + with patch( + "homeassistant.components.idasen_desk.config_flow.Desk.connect", + side_effect=exception, + ), patch("homeassistant.components.idasen_desk.config_flow.Desk.disconnect"): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_ADDRESS: IDASEN_DISCOVERY_INFO.address, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.FORM + assert result2["step_id"] == "user" + assert result2["errors"] == {"base": "cannot_connect"} + + with patch("homeassistant.components.idasen_desk.config_flow.Desk.connect"), patch( + "homeassistant.components.idasen_desk.config_flow.Desk.disconnect" + ), patch( + "homeassistant.components.idasen_desk.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_ADDRESS: IDASEN_DISCOVERY_INFO.address, + }, + ) + await hass.async_block_till_done() + + assert result3["type"] == FlowResultType.CREATE_ENTRY + assert result3["title"] == IDASEN_DISCOVERY_INFO.name + assert result3["data"] == { + CONF_ADDRESS: IDASEN_DISCOVERY_INFO.address, + } + assert result3["result"].unique_id == IDASEN_DISCOVERY_INFO.address + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_user_step_unknown_exception(hass: HomeAssistant) -> None: + """Test user step with an unknown exception.""" + with patch( + "homeassistant.components.idasen_desk.config_flow.async_discovered_service_info", + return_value=[NOT_IDASEN_DISCOVERY_INFO, IDASEN_DISCOVERY_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + with patch( + "homeassistant.components.idasen_desk.config_flow.Desk.connect", + side_effect=RuntimeError, + ), patch( + "homeassistant.components.idasen_desk.config_flow.Desk.disconnect", + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_ADDRESS: IDASEN_DISCOVERY_INFO.address, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.FORM + assert result2["step_id"] == "user" + assert result2["errors"] == {"base": "unknown"} + + with patch( + "homeassistant.components.idasen_desk.config_flow.Desk.connect", + ), patch( + "homeassistant.components.idasen_desk.config_flow.Desk.disconnect", + ), patch( + "homeassistant.components.idasen_desk.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_ADDRESS: IDASEN_DISCOVERY_INFO.address, + }, + ) + await hass.async_block_till_done() + + assert result3["type"] == FlowResultType.CREATE_ENTRY + assert result3["title"] == IDASEN_DISCOVERY_INFO.name + assert result3["data"] == { + CONF_ADDRESS: IDASEN_DISCOVERY_INFO.address, + } + assert result3["result"].unique_id == IDASEN_DISCOVERY_INFO.address + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_bluetooth_step_success(hass: HomeAssistant) -> None: + """Test bluetooth step success path.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=IDASEN_DISCOVERY_INFO, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + with patch("homeassistant.components.idasen_desk.config_flow.Desk.connect"), patch( + "homeassistant.components.idasen_desk.config_flow.Desk.disconnect" + ), patch( + "homeassistant.components.idasen_desk.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_ADDRESS: IDASEN_DISCOVERY_INFO.address, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == IDASEN_DISCOVERY_INFO.name + assert result2["data"] == { + CONF_ADDRESS: IDASEN_DISCOVERY_INFO.address, + } + assert result2["result"].unique_id == IDASEN_DISCOVERY_INFO.address + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/idasen_desk/test_cover.py b/tests/components/idasen_desk/test_cover.py new file mode 100644 index 00000000000000..a9c74be7081ae5 --- /dev/null +++ b/tests/components/idasen_desk/test_cover.py @@ -0,0 +1,82 @@ +"""Test the IKEA Idasen Desk cover.""" +from typing import Any +from unittest.mock import MagicMock + +import pytest + +from homeassistant.components.cover import ( + ATTR_CURRENT_POSITION, + ATTR_POSITION, + DOMAIN as COVER_DOMAIN, +) +from homeassistant.const import ( + SERVICE_CLOSE_COVER, + SERVICE_OPEN_COVER, + SERVICE_SET_COVER_POSITION, + SERVICE_STOP_COVER, + STATE_CLOSED, + STATE_OPEN, + STATE_UNAVAILABLE, +) +from homeassistant.core import HomeAssistant + +from . import init_integration + + +async def test_cover_available( + hass: HomeAssistant, + mock_desk_api: MagicMock, +) -> None: + """Test cover available property.""" + entity_id = "cover.test" + await init_integration(hass) + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OPEN + assert state.attributes[ATTR_CURRENT_POSITION] == 60 + + mock_desk_api.is_connected = False + mock_desk_api.trigger_update_callback(None) + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_UNAVAILABLE + + +@pytest.mark.parametrize( + ("service", "service_data", "expected_state", "expected_position"), + [ + (SERVICE_SET_COVER_POSITION, {ATTR_POSITION: 100}, STATE_OPEN, 100), + (SERVICE_SET_COVER_POSITION, {ATTR_POSITION: 0}, STATE_CLOSED, 0), + (SERVICE_OPEN_COVER, {}, STATE_OPEN, 100), + (SERVICE_CLOSE_COVER, {}, STATE_CLOSED, 0), + (SERVICE_STOP_COVER, {}, STATE_OPEN, 60), + ], +) +async def test_cover_services( + hass: HomeAssistant, + mock_desk_api: MagicMock, + service: str, + service_data: dict[str, Any], + expected_state: str, + expected_position: int, +) -> None: + """Test cover services.""" + entity_id = "cover.test" + await init_integration(hass) + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OPEN + assert state.attributes[ATTR_CURRENT_POSITION] == 60 + await hass.services.async_call( + COVER_DOMAIN, + service, + {"entity_id": entity_id, **service_data}, + blocking=True, + ) + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state + assert state.state == expected_state + assert state.attributes[ATTR_CURRENT_POSITION] == expected_position diff --git a/tests/components/idasen_desk/test_init.py b/tests/components/idasen_desk/test_init.py new file mode 100644 index 00000000000000..e596f0fe000b15 --- /dev/null +++ b/tests/components/idasen_desk/test_init.py @@ -0,0 +1,55 @@ +"""Test the IKEA Idasen Desk init.""" +from unittest.mock import AsyncMock, MagicMock + +from bleak import BleakError +import pytest + +from homeassistant.components.idasen_desk.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.core import HomeAssistant + +from . import init_integration + + +async def test_setup_and_shutdown( + hass: HomeAssistant, + mock_desk_api: MagicMock, +) -> None: + """Test setup.""" + entry = await init_integration(hass) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert entry.state is ConfigEntryState.LOADED + mock_desk_api.connect.assert_called_once() + mock_desk_api.is_connected = True + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + mock_desk_api.disconnect.assert_called_once() + + +@pytest.mark.parametrize("exception", [TimeoutError(), BleakError()]) +async def test_setup_connect_exception( + hass: HomeAssistant, mock_desk_api: MagicMock, exception: Exception +) -> None: + """Test setup with an connection exception.""" + mock_desk_api.connect = AsyncMock(side_effect=exception) + entry = await init_integration(hass) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_unload_entry(hass: HomeAssistant, mock_desk_api: MagicMock) -> None: + """Test successful unload of entry.""" + entry = await init_integration(hass) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert entry.state is ConfigEntryState.LOADED + mock_desk_api.connect.assert_called_once() + mock_desk_api.is_connected = True + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + mock_desk_api.disconnect.assert_called_once() + + assert entry.state is ConfigEntryState.NOT_LOADED + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 diff --git a/tests/components/imap/const.py b/tests/components/imap/const.py index e7fca106ff76cc..ec864fd4665ab0 100644 --- a/tests/components/imap/const.py +++ b/tests/components/imap/const.py @@ -22,6 +22,7 @@ b"To: notify@example.com\r\n" b"From: John Doe \r\n" b"Subject: Test subject\r\n" + b"Message-ID: " ) TEST_MESSAGE_HEADERS3 = b"" diff --git a/tests/components/imap/test_init.py b/tests/components/imap/test_init.py index b4ee11ba787d2f..ceda841202c1a4 100644 --- a/tests/components/imap/test_init.py +++ b/tests/components/imap/test_init.py @@ -512,6 +512,7 @@ async def _sleep_till_event() -> None: assert data["sender"] == "john.doe@example.com" assert data["subject"] == "Test subject" assert data["text"] + assert data["initial"] assert ( valid_date and isinstance(data["date"], datetime) @@ -628,7 +629,7 @@ async def test_message_is_truncated( [ ("{{ subject }}", "Test subject", None), ('{{ "@example.com" in sender }}', True, None), - ("{% bad template }}", None, "Error rendering imap custom template"), + ("{% bad template }}", None, "Error rendering IMAP custom template"), ], ids=["subject_test", "sender_filter", "template_error"], ) diff --git a/tests/components/ipp/__init__.py b/tests/components/ipp/__init__.py index f66630b2a6963e..ca374bd7e5ee2e 100644 --- a/tests/components/ipp/__init__.py +++ b/tests/components/ipp/__init__.py @@ -1,5 +1,7 @@ """Tests for the IPP integration.""" +from ipaddress import ip_address + from homeassistant.components import zeroconf from homeassistant.components.ipp.const import CONF_BASE_PATH from homeassistant.const import CONF_HOST, CONF_PORT, CONF_SSL, CONF_VERIFY_SSL @@ -31,8 +33,8 @@ MOCK_ZEROCONF_IPP_SERVICE_INFO = zeroconf.ZeroconfServiceInfo( type=IPP_ZEROCONF_SERVICE_TYPE, name=f"{ZEROCONF_NAME}.{IPP_ZEROCONF_SERVICE_TYPE}", - host=ZEROCONF_HOST, - addresses=[ZEROCONF_HOST], + ip_address=ip_address(ZEROCONF_HOST), + ip_addresses=[ip_address(ZEROCONF_HOST)], hostname=ZEROCONF_HOSTNAME, port=ZEROCONF_PORT, properties={"rp": ZEROCONF_RP}, @@ -41,8 +43,8 @@ MOCK_ZEROCONF_IPPS_SERVICE_INFO = zeroconf.ZeroconfServiceInfo( type=IPPS_ZEROCONF_SERVICE_TYPE, name=f"{ZEROCONF_NAME}.{IPPS_ZEROCONF_SERVICE_TYPE}", - host=ZEROCONF_HOST, - addresses=[ZEROCONF_HOST], + ip_address=ip_address(ZEROCONF_HOST), + ip_addresses=[ip_address(ZEROCONF_HOST)], hostname=ZEROCONF_HOSTNAME, port=ZEROCONF_PORT, properties={"rp": ZEROCONF_RP}, diff --git a/tests/components/ipp/fixtures/printer_without_uuid.json b/tests/components/ipp/fixtures/printer_without_uuid.json new file mode 100644 index 00000000000000..21f1eb93a32ba9 --- /dev/null +++ b/tests/components/ipp/fixtures/printer_without_uuid.json @@ -0,0 +1,35 @@ +{ + "printer-state": "idle", + "printer-name": "Test Printer", + "printer-location": null, + "printer-make-and-model": "Test HA-1000 Series", + "printer-device-id": "MFG:TEST;CMD:ESCPL2,BDC,D4,D4PX,ESCPR7,END4,GENEP,URF;MDL:HA-1000 Series;CLS:PRINTER;DES:TEST HA-1000 Series;CID:EpsonRGB;FID:FXN,DPA,WFA,ETN,AFN,DAN,WRA;RID:20;DDS:022500;ELG:1000;SN:555534593035345555;URF:CP1,PQ4-5,OB9,OFU0,RS360,SRGB24,W8,DM3,IS1-7-6,V1.4,MT1-3-7-8-10-11-12;", + "printer-uri-supported": [ + "ipps://192.168.1.31:631/ipp/print", + "ipp://192.168.1.31:631/ipp/print" + ], + "uri-authentication-supported": ["none", "none"], + "uri-security-supported": ["tls", "none"], + "printer-info": "Test HA-1000 Series", + "printer-up-time": 30, + "printer-firmware-string-version": "20.23.06HA", + "printer-more-info": "http://192.168.1.31:80/PRESENTATION/BONJOUR", + "marker-names": [ + "Black ink", + "Photo black ink", + "Cyan ink", + "Yellow ink", + "Magenta ink" + ], + "marker-types": [ + "ink-cartridge", + "ink-cartridge", + "ink-cartridge", + "ink-cartridge", + "ink-cartridge" + ], + "marker-colors": ["#000000", "#000000", "#00FFFF", "#FFFF00", "#FF00FF"], + "marker-levels": [58, 98, 91, 95, 73], + "marker-low-levels": [10, 10, 10, 10, 10], + "marker-high-levels": [100, 100, 100, 100, 100] +} diff --git a/tests/components/ipp/test_config_flow.py b/tests/components/ipp/test_config_flow.py index 69a2bb9287a8c1..5dd6c1af5bf018 100644 --- a/tests/components/ipp/test_config_flow.py +++ b/tests/components/ipp/test_config_flow.py @@ -1,6 +1,8 @@ """Tests for the IPP config flow.""" import dataclasses -from unittest.mock import MagicMock +from ipaddress import ip_address +import json +from unittest.mock import MagicMock, patch from pyipp import ( IPPConnectionError, @@ -8,6 +10,7 @@ IPPError, IPPParseError, IPPVersionNotSupportedError, + Printer, ) import pytest @@ -23,7 +26,7 @@ MOCK_ZEROCONF_IPPS_SERVICE_INFO, ) -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, load_fixture pytestmark = pytest.mark.usefixtures("mock_setup_entry") @@ -316,6 +319,33 @@ async def test_zeroconf_with_uuid_device_exists_abort( assert result["reason"] == "already_configured" +async def test_zeroconf_with_uuid_device_exists_abort_new_host( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_ipp_config_flow: MagicMock, +) -> None: + """Test we abort zeroconf flow if printer already configured.""" + mock_config_entry.add_to_hass(hass) + + discovery_info = dataclasses.replace( + MOCK_ZEROCONF_IPP_SERVICE_INFO, ip_address=ip_address("1.2.3.9") + ) + discovery_info.properties = { + **MOCK_ZEROCONF_IPP_SERVICE_INFO.properties, + "UUID": "cfe92100-67c4-11d4-a45f-f8d027761251", + } + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=discovery_info, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert mock_config_entry.data[CONF_HOST] == "1.2.3.9" + + async def test_zeroconf_empty_unique_id( hass: HomeAssistant, mock_ipp_config_flow: MagicMock, @@ -337,6 +367,21 @@ async def test_zeroconf_empty_unique_id( assert result["type"] == FlowResultType.FORM + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "192.168.1.31", CONF_BASE_PATH: "/ipp/print"}, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "EPSON XP-6000 Series" + + assert result["data"] + assert result["data"][CONF_HOST] == "192.168.1.31" + assert result["data"][CONF_UUID] == "cfe92100-67c4-11d4-a45f-f8d027761251" + + assert result["result"] + assert result["result"].unique_id == "cfe92100-67c4-11d4-a45f-f8d027761251" + async def test_zeroconf_no_unique_id( hass: HomeAssistant, @@ -355,6 +400,21 @@ async def test_zeroconf_no_unique_id( assert result["type"] == FlowResultType.FORM + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "192.168.1.31", CONF_BASE_PATH: "/ipp/print"}, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "EPSON XP-6000 Series" + + assert result["data"] + assert result["data"][CONF_HOST] == "192.168.1.31" + assert result["data"][CONF_UUID] == "cfe92100-67c4-11d4-a45f-f8d027761251" + + assert result["result"] + assert result["result"].unique_id == "cfe92100-67c4-11d4-a45f-f8d027761251" + async def test_full_user_flow_implementation( hass: HomeAssistant, @@ -448,3 +508,45 @@ async def test_full_zeroconf_tls_flow_implementation( assert result["result"] assert result["result"].unique_id == "cfe92100-67c4-11d4-a45f-f8d027761251" + + +async def test_zeroconf_empty_unique_id_uses_serial(hass: HomeAssistant) -> None: + """Test zeroconf flow if printer lacks (empty) unique identification with serial fallback.""" + fixture = await hass.async_add_executor_job( + load_fixture, "ipp/printer_without_uuid.json" + ) + mock_printer_without_uuid = Printer.from_dict(json.loads(fixture)) + mock_printer_without_uuid.unique_id = None + + discovery_info = dataclasses.replace(MOCK_ZEROCONF_IPP_SERVICE_INFO) + discovery_info.properties = { + **MOCK_ZEROCONF_IPP_SERVICE_INFO.properties, + "UUID": "", + } + with patch( + "homeassistant.components.ipp.config_flow.IPP", autospec=True + ) as ipp_mock: + client = ipp_mock.return_value + client.printer.return_value = mock_printer_without_uuid + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=discovery_info, + ) + + assert result["type"] == FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "192.168.1.31", CONF_BASE_PATH: "/ipp/print"}, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "EPSON XP-6000 Series" + + assert result["data"] + assert result["data"][CONF_HOST] == "192.168.1.31" + assert result["data"][CONF_UUID] == "" + + assert result["result"] + assert result["result"].unique_id == "555534593035345555" diff --git a/tests/components/islamic_prayer_times/test_config_flow.py b/tests/components/islamic_prayer_times/test_config_flow.py index a25b8ba0f0b3c7..f331c5bf49b657 100644 --- a/tests/components/islamic_prayer_times/test_config_flow.py +++ b/tests/components/islamic_prayer_times/test_config_flow.py @@ -3,7 +3,13 @@ from homeassistant import config_entries, data_entry_flow from homeassistant.components import islamic_prayer_times -from homeassistant.components.islamic_prayer_times.const import CONF_CALC_METHOD, DOMAIN +from homeassistant.components.islamic_prayer_times.const import ( + CONF_CALC_METHOD, + CONF_LAT_ADJ_METHOD, + CONF_MIDNIGHT_MODE, + CONF_SCHOOL, + DOMAIN, +) from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -44,11 +50,19 @@ async def test_options(hass: HomeAssistant) -> None: assert result["step_id"] == "init" result = await hass.config_entries.options.async_configure( - result["flow_id"], user_input={CONF_CALC_METHOD: "makkah"} + result["flow_id"], + user_input={ + CONF_CALC_METHOD: "makkah", + CONF_LAT_ADJ_METHOD: "one_seventh", + CONF_SCHOOL: "hanafi", + }, ) assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY assert result["data"][CONF_CALC_METHOD] == "makkah" + assert result["data"][CONF_LAT_ADJ_METHOD] == "one_seventh" + assert result["data"][CONF_MIDNIGHT_MODE] == "standard" + assert result["data"][CONF_SCHOOL] == "hanafi" async def test_integration_already_configured(hass: HomeAssistant) -> None: diff --git a/tests/components/kodi/util.py b/tests/components/kodi/util.py index 9fb215e2d8a963..2b9d819c244760 100644 --- a/tests/components/kodi/util.py +++ b/tests/components/kodi/util.py @@ -1,4 +1,6 @@ """Test the Kodi config flow.""" +from ipaddress import ip_address + from homeassistant.components import zeroconf from homeassistant.components.kodi.const import DEFAULT_SSL @@ -8,7 +10,6 @@ "ssl": DEFAULT_SSL, } - TEST_CREDENTIALS = {"username": "username", "password": "password"} @@ -16,8 +17,8 @@ UUID = "11111111-1111-1111-1111-111111111111" TEST_DISCOVERY = zeroconf.ZeroconfServiceInfo( - host="1.1.1.1", - addresses=["1.1.1.1"], + ip_address=ip_address("1.1.1.1"), + ip_addresses=[ip_address("1.1.1.1")], port=8080, hostname="hostname.local.", type="_xbmc-jsonrpc-h._tcp.local.", @@ -27,8 +28,8 @@ TEST_DISCOVERY_WO_UUID = zeroconf.ZeroconfServiceInfo( - host="1.1.1.1", - addresses=["1.1.1.1"], + ip_address=ip_address("1.1.1.1"), + ip_addresses=[ip_address("1.1.1.1")], port=8080, hostname="hostname.local.", type="_xbmc-jsonrpc-h._tcp.local.", diff --git a/tests/components/lifx/test_config_flow.py b/tests/components/lifx/test_config_flow.py index 2adea42bed47e2..1b7da4f864a70b 100644 --- a/tests/components/lifx/test_config_flow.py +++ b/tests/components/lifx/test_config_flow.py @@ -1,4 +1,5 @@ """Tests for the lifx integration config flow.""" +from ipaddress import ip_address import socket from unittest.mock import patch @@ -388,8 +389,8 @@ async def test_discovered_by_discovery_and_dhcp(hass: HomeAssistant) -> None: ( config_entries.SOURCE_HOMEKIT, zeroconf.ZeroconfServiceInfo( - host=IP_ADDRESS, - addresses=[IP_ADDRESS], + ip_address=ip_address(IP_ADDRESS), + ip_addresses=[ip_address(IP_ADDRESS)], hostname=LABEL, name=LABEL, port=None, @@ -443,8 +444,8 @@ async def test_discovered_by_dhcp_or_discovery( ( config_entries.SOURCE_HOMEKIT, zeroconf.ZeroconfServiceInfo( - host=IP_ADDRESS, - addresses=[IP_ADDRESS], + ip_address=ip_address(IP_ADDRESS), + ip_addresses=[ip_address(IP_ADDRESS)], hostname=LABEL, name=LABEL, port=None, @@ -484,8 +485,8 @@ async def test_discovered_by_dhcp_or_discovery_failed_to_get_device( ( config_entries.SOURCE_HOMEKIT, zeroconf.ZeroconfServiceInfo( - host=IP_ADDRESS, - addresses=[IP_ADDRESS], + ip_address=ip_address(IP_ADDRESS), + ip_addresses=[ip_address(IP_ADDRESS)], hostname=LABEL, name=LABEL, port=None, diff --git a/tests/components/london_underground/__init__.py b/tests/components/london_underground/__init__.py new file mode 100644 index 00000000000000..5de380bde1c47b --- /dev/null +++ b/tests/components/london_underground/__init__.py @@ -0,0 +1 @@ +"""Tests for the london_underground component.""" diff --git a/tests/components/london_underground/fixtures/line_status.json b/tests/components/london_underground/fixtures/line_status.json new file mode 100644 index 00000000000000..a014fc168c6f80 --- /dev/null +++ b/tests/components/london_underground/fixtures/line_status.json @@ -0,0 +1,514 @@ +[ + { + "$type": "Tfl.Api.Presentation.Entities.Line, Tfl.Api.Presentation.Entities", + "id": "bakerloo", + "name": "Bakerloo", + "modeName": "tube", + "disruptions": [], + "created": "2023-09-11T10:28:16.97Z", + "modified": "2023-09-11T10:28:16.97Z", + "lineStatuses": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineStatus, Tfl.Api.Presentation.Entities", + "id": 0, + "statusSeverity": 10, + "statusSeverityDescription": "Good Service", + "created": "0001-01-01T00:00:00", + "validityPeriods": [] + } + ], + "routeSections": [], + "serviceTypes": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineServiceTypeInfo, Tfl.Api.Presentation.Entities", + "name": "Regular", + "uri": "/Line/Route?ids=Bakerloo&serviceTypes=Regular" + } + ], + "crowding": { + "$type": "Tfl.Api.Presentation.Entities.Crowding, Tfl.Api.Presentation.Entities" + } + }, + { + "$type": "Tfl.Api.Presentation.Entities.Line, Tfl.Api.Presentation.Entities", + "id": "central", + "name": "Central", + "modeName": "tube", + "disruptions": [], + "created": "2023-09-11T10:28:16.987Z", + "modified": "2023-09-11T10:28:16.987Z", + "lineStatuses": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineStatus, Tfl.Api.Presentation.Entities", + "id": 0, + "statusSeverity": 10, + "statusSeverityDescription": "Good Service", + "created": "0001-01-01T00:00:00", + "validityPeriods": [] + } + ], + "routeSections": [], + "serviceTypes": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineServiceTypeInfo, Tfl.Api.Presentation.Entities", + "name": "Regular", + "uri": "/Line/Route?ids=Central&serviceTypes=Regular" + }, + { + "$type": "Tfl.Api.Presentation.Entities.LineServiceTypeInfo, Tfl.Api.Presentation.Entities", + "name": "Night", + "uri": "/Line/Route?ids=Central&serviceTypes=Night" + } + ], + "crowding": { + "$type": "Tfl.Api.Presentation.Entities.Crowding, Tfl.Api.Presentation.Entities" + } + }, + { + "$type": "Tfl.Api.Presentation.Entities.Line, Tfl.Api.Presentation.Entities", + "id": "circle", + "name": "Circle", + "modeName": "tube", + "disruptions": [], + "created": "2023-09-11T10:28:16.97Z", + "modified": "2023-09-11T10:28:16.97Z", + "lineStatuses": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineStatus, Tfl.Api.Presentation.Entities", + "id": 0, + "statusSeverity": 10, + "statusSeverityDescription": "Good Service", + "created": "0001-01-01T00:00:00", + "validityPeriods": [] + } + ], + "routeSections": [], + "serviceTypes": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineServiceTypeInfo, Tfl.Api.Presentation.Entities", + "name": "Regular", + "uri": "/Line/Route?ids=Circle&serviceTypes=Regular" + } + ], + "crowding": { + "$type": "Tfl.Api.Presentation.Entities.Crowding, Tfl.Api.Presentation.Entities" + } + }, + { + "$type": "Tfl.Api.Presentation.Entities.Line, Tfl.Api.Presentation.Entities", + "id": "district", + "name": "District", + "modeName": "tube", + "disruptions": [], + "created": "2023-09-11T10:28:16.97Z", + "modified": "2023-09-11T10:28:16.97Z", + "lineStatuses": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineStatus, Tfl.Api.Presentation.Entities", + "id": 0, + "lineId": "district", + "statusSeverity": 3, + "statusSeverityDescription": "Part Suspended", + "reason": "District Line: No service between Turnham Green and Ealing Broadway while we remove a tree from the track at Ealing Common. Valid tickets will be accepted on local buses. GOOD SERVICE on the rest of the line ", + "created": "0001-01-01T00:00:00", + "validityPeriods": [ + { + "$type": "Tfl.Api.Presentation.Entities.ValidityPeriod, Tfl.Api.Presentation.Entities", + "fromDate": "2023-09-18T18:25:36Z", + "toDate": "2023-09-18T22:06:14Z", + "isNow": true + } + ], + "disruption": { + "$type": "Tfl.Api.Presentation.Entities.Disruption, Tfl.Api.Presentation.Entities", + "category": "RealTime", + "categoryDescription": "RealTime", + "description": "District Line: No service between Turnham Green and Ealing Broadway while we remove a tree from the track at Ealing Common. Valid tickets will be accepted on local buses. GOOD SERVICE on the rest of the line ", + "affectedRoutes": [], + "affectedStops": [], + "closureText": "partSuspended" + } + } + ], + "routeSections": [], + "serviceTypes": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineServiceTypeInfo, Tfl.Api.Presentation.Entities", + "name": "Regular", + "uri": "/Line/Route?ids=District&serviceTypes=Regular" + } + ], + "crowding": { + "$type": "Tfl.Api.Presentation.Entities.Crowding, Tfl.Api.Presentation.Entities" + } + }, + { + "$type": "Tfl.Api.Presentation.Entities.Line, Tfl.Api.Presentation.Entities", + "id": "dlr", + "name": "DLR", + "modeName": "dlr", + "disruptions": [], + "created": "2023-09-11T10:28:16.987Z", + "modified": "2023-09-11T10:28:16.987Z", + "lineStatuses": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineStatus, Tfl.Api.Presentation.Entities", + "id": 0, + "statusSeverity": 10, + "statusSeverityDescription": "Good Service", + "created": "0001-01-01T00:00:00", + "validityPeriods": [] + } + ], + "routeSections": [], + "serviceTypes": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineServiceTypeInfo, Tfl.Api.Presentation.Entities", + "name": "Regular", + "uri": "/Line/Route?ids=DLR&serviceTypes=Regular" + } + ], + "crowding": { + "$type": "Tfl.Api.Presentation.Entities.Crowding, Tfl.Api.Presentation.Entities" + } + }, + { + "$type": "Tfl.Api.Presentation.Entities.Line, Tfl.Api.Presentation.Entities", + "id": "elizabeth", + "name": "Elizabeth line", + "modeName": "elizabeth-line", + "disruptions": [], + "created": "2023-09-11T10:28:16.97Z", + "modified": "2023-09-11T10:28:16.97Z", + "lineStatuses": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineStatus, Tfl.Api.Presentation.Entities", + "id": 0, + "statusSeverity": 10, + "statusSeverityDescription": "Good Service", + "created": "0001-01-01T00:00:00", + "validityPeriods": [] + } + ], + "routeSections": [], + "serviceTypes": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineServiceTypeInfo, Tfl.Api.Presentation.Entities", + "name": "Regular", + "uri": "/Line/Route?ids=Elizabeth line&serviceTypes=Regular" + } + ], + "crowding": { + "$type": "Tfl.Api.Presentation.Entities.Crowding, Tfl.Api.Presentation.Entities" + } + }, + { + "$type": "Tfl.Api.Presentation.Entities.Line, Tfl.Api.Presentation.Entities", + "id": "hammersmith-city", + "name": "Hammersmith & City", + "modeName": "tube", + "disruptions": [], + "created": "2023-09-11T10:28:16.97Z", + "modified": "2023-09-11T10:28:16.97Z", + "lineStatuses": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineStatus, Tfl.Api.Presentation.Entities", + "id": 0, + "statusSeverity": 10, + "statusSeverityDescription": "Good Service", + "created": "0001-01-01T00:00:00", + "validityPeriods": [] + } + ], + "routeSections": [], + "serviceTypes": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineServiceTypeInfo, Tfl.Api.Presentation.Entities", + "name": "Regular", + "uri": "/Line/Route?ids=Hammersmith & City&serviceTypes=Regular" + } + ], + "crowding": { + "$type": "Tfl.Api.Presentation.Entities.Crowding, Tfl.Api.Presentation.Entities" + } + }, + { + "$type": "Tfl.Api.Presentation.Entities.Line, Tfl.Api.Presentation.Entities", + "id": "jubilee", + "name": "Jubilee", + "modeName": "tube", + "disruptions": [], + "created": "2023-09-11T10:28:16.97Z", + "modified": "2023-09-11T10:28:16.97Z", + "lineStatuses": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineStatus, Tfl.Api.Presentation.Entities", + "id": 0, + "statusSeverity": 10, + "statusSeverityDescription": "Good Service", + "created": "0001-01-01T00:00:00", + "validityPeriods": [] + } + ], + "routeSections": [], + "serviceTypes": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineServiceTypeInfo, Tfl.Api.Presentation.Entities", + "name": "Regular", + "uri": "/Line/Route?ids=Jubilee&serviceTypes=Regular" + }, + { + "$type": "Tfl.Api.Presentation.Entities.LineServiceTypeInfo, Tfl.Api.Presentation.Entities", + "name": "Night", + "uri": "/Line/Route?ids=Jubilee&serviceTypes=Night" + } + ], + "crowding": { + "$type": "Tfl.Api.Presentation.Entities.Crowding, Tfl.Api.Presentation.Entities" + } + }, + { + "$type": "Tfl.Api.Presentation.Entities.Line, Tfl.Api.Presentation.Entities", + "id": "london-overground", + "name": "London Overground", + "modeName": "overground", + "disruptions": [], + "created": "2023-09-11T10:28:16.97Z", + "modified": "2023-09-11T10:28:16.97Z", + "lineStatuses": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineStatus, Tfl.Api.Presentation.Entities", + "id": 0, + "statusSeverity": 10, + "statusSeverityDescription": "Good Service", + "created": "0001-01-01T00:00:00", + "validityPeriods": [] + } + ], + "routeSections": [], + "serviceTypes": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineServiceTypeInfo, Tfl.Api.Presentation.Entities", + "name": "Regular", + "uri": "/Line/Route?ids=London Overground&serviceTypes=Regular" + }, + { + "$type": "Tfl.Api.Presentation.Entities.LineServiceTypeInfo, Tfl.Api.Presentation.Entities", + "name": "Night", + "uri": "/Line/Route?ids=London Overground&serviceTypes=Night" + } + ], + "crowding": { + "$type": "Tfl.Api.Presentation.Entities.Crowding, Tfl.Api.Presentation.Entities" + } + }, + { + "$type": "Tfl.Api.Presentation.Entities.Line, Tfl.Api.Presentation.Entities", + "id": "metropolitan", + "name": "Metropolitan", + "modeName": "tube", + "disruptions": [], + "created": "2023-09-11T10:28:16.97Z", + "modified": "2023-09-11T10:28:16.97Z", + "lineStatuses": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineStatus, Tfl.Api.Presentation.Entities", + "id": 0, + "statusSeverity": 10, + "statusSeverityDescription": "Good Service", + "created": "0001-01-01T00:00:00", + "validityPeriods": [] + } + ], + "routeSections": [], + "serviceTypes": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineServiceTypeInfo, Tfl.Api.Presentation.Entities", + "name": "Regular", + "uri": "/Line/Route?ids=Metropolitan&serviceTypes=Regular" + } + ], + "crowding": { + "$type": "Tfl.Api.Presentation.Entities.Crowding, Tfl.Api.Presentation.Entities" + } + }, + { + "$type": "Tfl.Api.Presentation.Entities.Line, Tfl.Api.Presentation.Entities", + "id": "northern", + "name": "Northern", + "modeName": "tube", + "disruptions": [], + "created": "2023-09-11T10:28:16.97Z", + "modified": "2023-09-11T10:28:16.97Z", + "lineStatuses": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineStatus, Tfl.Api.Presentation.Entities", + "id": 0, + "statusSeverity": 10, + "statusSeverityDescription": "Good Service", + "created": "0001-01-01T00:00:00", + "validityPeriods": [] + } + ], + "routeSections": [], + "serviceTypes": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineServiceTypeInfo, Tfl.Api.Presentation.Entities", + "name": "Regular", + "uri": "/Line/Route?ids=Northern&serviceTypes=Regular" + }, + { + "$type": "Tfl.Api.Presentation.Entities.LineServiceTypeInfo, Tfl.Api.Presentation.Entities", + "name": "Night", + "uri": "/Line/Route?ids=Northern&serviceTypes=Night" + } + ], + "crowding": { + "$type": "Tfl.Api.Presentation.Entities.Crowding, Tfl.Api.Presentation.Entities" + } + }, + { + "$type": "Tfl.Api.Presentation.Entities.Line, Tfl.Api.Presentation.Entities", + "id": "piccadilly", + "name": "Piccadilly", + "modeName": "tube", + "disruptions": [], + "created": "2023-09-11T10:28:16.97Z", + "modified": "2023-09-11T10:28:16.97Z", + "lineStatuses": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineStatus, Tfl.Api.Presentation.Entities", + "id": 0, + "lineId": "piccadilly", + "statusSeverity": 6, + "statusSeverityDescription": "Severe Delays", + "reason": "Piccadilly Line: No service between Acton Town and Uxbridge while we remove a tree from the track at Ealing Common. Severe delays between Acton Town and Cockfosters, eastbound only. GOOD SERVICE on the rest of the line. Valid tickets will be accepted on local buses, London Overground, Great Northern and South Western Railway. ", + "created": "0001-01-01T00:00:00", + "validityPeriods": [ + { + "$type": "Tfl.Api.Presentation.Entities.ValidityPeriod, Tfl.Api.Presentation.Entities", + "fromDate": "2023-09-18T19:01:20Z", + "toDate": "2023-09-19T00:29:00Z", + "isNow": true + } + ], + "disruption": { + "$type": "Tfl.Api.Presentation.Entities.Disruption, Tfl.Api.Presentation.Entities", + "category": "RealTime", + "categoryDescription": "RealTime", + "description": "Piccadilly Line: No service between Acton Town and Uxbridge while we remove a tree from the track at Ealing Common. Severe delays between Acton Town and Cockfosters, eastbound only. GOOD SERVICE on the rest of the line. Valid tickets will be accepted on local buses, London Overground, Great Northern and South Western Railway. ", + "affectedRoutes": [], + "affectedStops": [], + "closureText": "severeDelays" + } + }, + { + "$type": "Tfl.Api.Presentation.Entities.LineStatus, Tfl.Api.Presentation.Entities", + "id": 0, + "lineId": "piccadilly", + "statusSeverity": 3, + "statusSeverityDescription": "Part Suspended", + "reason": "Piccadilly Line: No service between Acton Town and Uxbridge while we remove a tree from the track at Ealing Common. Severe delays between Acton Town and Cockfosters, eastbound only. GOOD SERVICE on the rest of the line. Valid tickets will be accepted on local buses, London Overground, Great Northern and South Western Railway. ", + "created": "0001-01-01T00:00:00", + "validityPeriods": [ + { + "$type": "Tfl.Api.Presentation.Entities.ValidityPeriod, Tfl.Api.Presentation.Entities", + "fromDate": "2023-09-18T19:01:20Z", + "toDate": "2023-09-18T22:06:14Z", + "isNow": true + } + ], + "disruption": { + "$type": "Tfl.Api.Presentation.Entities.Disruption, Tfl.Api.Presentation.Entities", + "category": "RealTime", + "categoryDescription": "RealTime", + "description": "Piccadilly Line: No service between Acton Town and Uxbridge while we remove a tree from the track at Ealing Common. Severe delays between Acton Town and Cockfosters, eastbound only. GOOD SERVICE on the rest of the line. Valid tickets will be accepted on local buses, London Overground, Great Northern and South Western Railway. ", + "affectedRoutes": [], + "affectedStops": [], + "closureText": "partSuspended" + } + } + ], + "routeSections": [], + "serviceTypes": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineServiceTypeInfo, Tfl.Api.Presentation.Entities", + "name": "Regular", + "uri": "/Line/Route?ids=Piccadilly&serviceTypes=Regular" + }, + { + "$type": "Tfl.Api.Presentation.Entities.LineServiceTypeInfo, Tfl.Api.Presentation.Entities", + "name": "Night", + "uri": "/Line/Route?ids=Piccadilly&serviceTypes=Night" + } + ], + "crowding": { + "$type": "Tfl.Api.Presentation.Entities.Crowding, Tfl.Api.Presentation.Entities" + } + }, + { + "$type": "Tfl.Api.Presentation.Entities.Line, Tfl.Api.Presentation.Entities", + "id": "victoria", + "name": "Victoria", + "modeName": "tube", + "disruptions": [], + "created": "2023-09-11T10:28:16.97Z", + "modified": "2023-09-11T10:28:16.97Z", + "lineStatuses": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineStatus, Tfl.Api.Presentation.Entities", + "id": 0, + "statusSeverity": 10, + "statusSeverityDescription": "Good Service", + "created": "0001-01-01T00:00:00", + "validityPeriods": [] + } + ], + "routeSections": [], + "serviceTypes": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineServiceTypeInfo, Tfl.Api.Presentation.Entities", + "name": "Regular", + "uri": "/Line/Route?ids=Victoria&serviceTypes=Regular" + }, + { + "$type": "Tfl.Api.Presentation.Entities.LineServiceTypeInfo, Tfl.Api.Presentation.Entities", + "name": "Night", + "uri": "/Line/Route?ids=Victoria&serviceTypes=Night" + } + ], + "crowding": { + "$type": "Tfl.Api.Presentation.Entities.Crowding, Tfl.Api.Presentation.Entities" + } + }, + { + "$type": "Tfl.Api.Presentation.Entities.Line, Tfl.Api.Presentation.Entities", + "id": "waterloo-city", + "name": "Waterloo & City", + "modeName": "tube", + "disruptions": [], + "created": "2023-09-11T10:28:16.987Z", + "modified": "2023-09-11T10:28:16.987Z", + "lineStatuses": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineStatus, Tfl.Api.Presentation.Entities", + "id": 0, + "statusSeverity": 10, + "statusSeverityDescription": "Good Service", + "created": "0001-01-01T00:00:00", + "validityPeriods": [] + } + ], + "routeSections": [], + "serviceTypes": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineServiceTypeInfo, Tfl.Api.Presentation.Entities", + "name": "Regular", + "uri": "/Line/Route?ids=Waterloo & City&serviceTypes=Regular" + } + ], + "crowding": { + "$type": "Tfl.Api.Presentation.Entities.Crowding, Tfl.Api.Presentation.Entities" + } + } +] diff --git a/tests/components/london_underground/test_sensor.py b/tests/components/london_underground/test_sensor.py new file mode 100644 index 00000000000000..4dda341279d7ad --- /dev/null +++ b/tests/components/london_underground/test_sensor.py @@ -0,0 +1,36 @@ +"""The tests for the london_underground platform.""" +from london_tube_status import API_URL + +from homeassistant.components.london_underground.const import CONF_LINE +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import load_fixture +from tests.test_util.aiohttp import AiohttpClientMocker + +VALID_CONFIG = { + "sensor": {"platform": "london_underground", CONF_LINE: ["Metropolitan"]} +} + + +async def test_valid_state( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test for operational london_underground sensor with proper attributes.""" + aioclient_mock.get( + API_URL, + text=load_fixture("line_status.json", "london_underground"), + ) + + assert await async_setup_component(hass, "sensor", VALID_CONFIG) + await hass.async_block_till_done() + + state = hass.states.get("sensor.metropolitan") + assert state + assert state.state == "Good Service" + assert state.attributes == { + "Description": "Nothing to report", + "attribution": "Powered by TfL Open Data", + "friendly_name": "Metropolitan", + "icon": "mdi:subway", + } diff --git a/tests/components/lookin/__init__.py b/tests/components/lookin/__init__.py index 11426f20e57898..bfbb5f66887443 100644 --- a/tests/components/lookin/__init__.py +++ b/tests/components/lookin/__init__.py @@ -1,6 +1,7 @@ """Tests for the lookin integration.""" from __future__ import annotations +from ipaddress import ip_address from unittest.mock import MagicMock, patch from aiolookin import Climate, Device, Remote @@ -18,8 +19,8 @@ ZC_NAME = f"LOOKin_{DEVICE_ID}" ZC_TYPE = "_lookin._tcp." ZEROCONF_DATA = ZeroconfServiceInfo( - host=IP_ADDRESS, - addresses=[IP_ADDRESS], + ip_address=ip_address(IP_ADDRESS), + ip_addresses=[ip_address(IP_ADDRESS)], hostname=f"{ZC_NAME.lower()}.local.", port=80, type=ZC_TYPE, diff --git a/tests/components/lookin/test_config_flow.py b/tests/components/lookin/test_config_flow.py index 1fd4479d100d8f..873e21a5cace5e 100644 --- a/tests/components/lookin/test_config_flow.py +++ b/tests/components/lookin/test_config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations import dataclasses +from ipaddress import ip_address from unittest.mock import patch from aiolookin import NoUsableService @@ -135,7 +136,7 @@ async def test_discovered_zeroconf(hass: HomeAssistant) -> None: entry = hass.config_entries.async_entries(DOMAIN)[0] zc_data_new_ip = dataclasses.replace(ZEROCONF_DATA) - zc_data_new_ip.host = "127.0.0.2" + zc_data_new_ip.ip_address = ip_address("127.0.0.2") with _patch_get_info(), patch( f"{MODULE}.async_setup_entry", return_value=True diff --git a/tests/components/loqed/test_config_flow.py b/tests/components/loqed/test_config_flow.py index c9c577e7199995..617b6818a64c4b 100644 --- a/tests/components/loqed/test_config_flow.py +++ b/tests/components/loqed/test_config_flow.py @@ -1,4 +1,5 @@ """Test the Loqed config flow.""" +from ipaddress import ip_address import json from unittest.mock import Mock, patch @@ -16,8 +17,8 @@ from tests.test_util.aiohttp import AiohttpClientMocker zeroconf_data = zeroconf.ZeroconfServiceInfo( - host="192.168.12.34", - addresses=["127.0.0.1"], + ip_address=ip_address("192.168.12.34"), + ip_addresses=[ip_address("192.168.12.34")], hostname="LOQED-ffeeddccbbaa.local", name="mock_name", port=9123, diff --git a/tests/components/lutron_caseta/test_config_flow.py b/tests/components/lutron_caseta/test_config_flow.py index 7f6a1b60511360..da26a55a4ef92e 100644 --- a/tests/components/lutron_caseta/test_config_flow.py +++ b/tests/components/lutron_caseta/test_config_flow.py @@ -1,5 +1,6 @@ """Test the Lutron Caseta config flow.""" import asyncio +from ipaddress import ip_address from pathlib import Path import ssl from unittest.mock import AsyncMock, patch @@ -404,8 +405,8 @@ async def test_zeroconf_host_already_configured( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="1.1.1.1", - addresses=["1.1.1.1"], + ip_address=ip_address("1.1.1.1"), + ip_addresses=[ip_address("1.1.1.1")], hostname="LuTrOn-abc.local.", name="mock_name", port=None, @@ -432,8 +433,8 @@ async def test_zeroconf_lutron_id_already_configured(hass: HomeAssistant) -> Non DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="1.1.1.1", - addresses=["1.1.1.1"], + ip_address=ip_address("1.1.1.1"), + ip_addresses=[ip_address("1.1.1.1")], hostname="LuTrOn-abc.local.", name="mock_name", port=None, @@ -455,8 +456,8 @@ async def test_zeroconf_not_lutron_device(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="1.1.1.1", - addresses=["1.1.1.1"], + ip_address=ip_address("1.1.1.1"), + ip_addresses=[ip_address("1.1.1.1")], hostname="notlutron-abc.local.", name="mock_name", port=None, @@ -483,8 +484,8 @@ async def test_zeroconf(hass: HomeAssistant, source, tmp_path: Path) -> None: DOMAIN, context={"source": source}, data=zeroconf.ZeroconfServiceInfo( - host="1.1.1.1", - addresses=["1.1.1.1"], + ip_address=ip_address("1.1.1.1"), + ip_addresses=[ip_address("1.1.1.1")], hostname="LuTrOn-abc.local.", name="mock_name", port=None, diff --git a/tests/components/matrix/__init__.py b/tests/components/matrix/__init__.py new file mode 100644 index 00000000000000..a520f7e7c23d0e --- /dev/null +++ b/tests/components/matrix/__init__.py @@ -0,0 +1 @@ +"""Tests for the Matrix component.""" diff --git a/tests/components/matrix/conftest.py b/tests/components/matrix/conftest.py new file mode 100644 index 00000000000000..d0970b96019b17 --- /dev/null +++ b/tests/components/matrix/conftest.py @@ -0,0 +1,248 @@ +"""Define fixtures available for all tests.""" +from __future__ import annotations + +import re +import tempfile +from unittest.mock import patch + +from nio import ( + AsyncClient, + ErrorResponse, + JoinError, + JoinResponse, + LocalProtocolError, + LoginError, + LoginResponse, + Response, + UploadResponse, + WhoamiError, + WhoamiResponse, +) +from PIL import Image +import pytest + +from homeassistant.components.matrix import ( + CONF_COMMANDS, + CONF_EXPRESSION, + CONF_HOMESERVER, + CONF_ROOMS, + CONF_WORD, + EVENT_MATRIX_COMMAND, + MatrixBot, + RoomID, +) +from homeassistant.components.matrix.const import DOMAIN as MATRIX_DOMAIN +from homeassistant.components.matrix.notify import CONF_DEFAULT_ROOM +from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN +from homeassistant.const import ( + CONF_NAME, + CONF_PASSWORD, + CONF_PLATFORM, + CONF_USERNAME, + CONF_VERIFY_SSL, +) +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import async_capture_events + +TEST_NOTIFIER_NAME = "matrix_notify" + +TEST_DEFAULT_ROOM = "!DefaultNotificationRoom:example.com" +TEST_JOINABLE_ROOMS = ["!RoomIdString:example.com", "#RoomAliasString:example.com"] +TEST_BAD_ROOM = "!UninvitedRoom:example.com" +TEST_MXID = "@user:example.com" +TEST_DEVICE_ID = "FAKEID" +TEST_PASSWORD = "password" +TEST_TOKEN = "access_token" + +NIO_IMPORT_PREFIX = "homeassistant.components.matrix.nio." + + +class _MockAsyncClient(AsyncClient): + """Mock class to simulate MatrixBot._client's I/O methods.""" + + async def close(self): + return None + + async def join(self, room_id: RoomID): + if room_id in TEST_JOINABLE_ROOMS: + return JoinResponse(room_id=room_id) + else: + return JoinError(message="Not allowed to join this room.") + + async def login(self, *args, **kwargs): + if kwargs.get("password") == TEST_PASSWORD or kwargs.get("token") == TEST_TOKEN: + self.access_token = TEST_TOKEN + return LoginResponse( + access_token=TEST_TOKEN, + device_id="test_device", + user_id=TEST_MXID, + ) + else: + self.access_token = "" + return LoginError(message="LoginError", status_code="status_code") + + async def logout(self, *args, **kwargs): + self.access_token = "" + + async def whoami(self): + if self.access_token == TEST_TOKEN: + self.user_id = TEST_MXID + self.device_id = TEST_DEVICE_ID + return WhoamiResponse( + user_id=TEST_MXID, device_id=TEST_DEVICE_ID, is_guest=False + ) + else: + self.access_token = "" + return WhoamiError( + message="Invalid access token passed.", status_code="M_UNKNOWN_TOKEN" + ) + + async def room_send(self, *args, **kwargs): + if not self.logged_in: + raise LocalProtocolError + if kwargs["room_id"] in TEST_JOINABLE_ROOMS: + return Response() + else: + return ErrorResponse(message="Cannot send a message in this room.") + + async def sync(self, *args, **kwargs): + return None + + async def sync_forever(self, *args, **kwargs): + return None + + async def upload(self, *args, **kwargs): + return UploadResponse(content_uri="mxc://example.com/randomgibberish"), None + + +MOCK_CONFIG_DATA = { + MATRIX_DOMAIN: { + CONF_HOMESERVER: "https://matrix.example.com", + CONF_USERNAME: TEST_MXID, + CONF_PASSWORD: TEST_PASSWORD, + CONF_VERIFY_SSL: True, + CONF_ROOMS: TEST_JOINABLE_ROOMS, + CONF_COMMANDS: [ + { + CONF_WORD: "WordTrigger", + CONF_NAME: "WordTriggerEventName", + }, + { + CONF_EXPRESSION: "My name is (?P.*)", + CONF_NAME: "ExpressionTriggerEventName", + }, + ], + }, + NOTIFY_DOMAIN: { + CONF_NAME: TEST_NOTIFIER_NAME, + CONF_PLATFORM: MATRIX_DOMAIN, + CONF_DEFAULT_ROOM: TEST_DEFAULT_ROOM, + }, +} + +MOCK_WORD_COMMANDS = { + "!RoomIdString:example.com": { + "WordTrigger": { + "word": "WordTrigger", + "name": "WordTriggerEventName", + "rooms": ["!RoomIdString:example.com", "#RoomAliasString:example.com"], + } + }, + "#RoomAliasString:example.com": { + "WordTrigger": { + "word": "WordTrigger", + "name": "WordTriggerEventName", + "rooms": ["!RoomIdString:example.com", "#RoomAliasString:example.com"], + } + }, +} + +MOCK_EXPRESSION_COMMANDS = { + "!RoomIdString:example.com": [ + { + "expression": re.compile("My name is (?P.*)"), + "name": "ExpressionTriggerEventName", + "rooms": ["!RoomIdString:example.com", "#RoomAliasString:example.com"], + } + ], + "#RoomAliasString:example.com": [ + { + "expression": re.compile("My name is (?P.*)"), + "name": "ExpressionTriggerEventName", + "rooms": ["!RoomIdString:example.com", "#RoomAliasString:example.com"], + } + ], +} + + +@pytest.fixture +def mock_client(): + """Return mocked AsyncClient.""" + with patch("homeassistant.components.matrix.AsyncClient", _MockAsyncClient) as mock: + yield mock + + +@pytest.fixture +def mock_save_json(): + """Prevent saving test access_tokens.""" + with patch("homeassistant.components.matrix.save_json") as mock: + yield mock + + +@pytest.fixture +def mock_load_json(): + """Mock loading access_tokens from a file.""" + with patch( + "homeassistant.components.matrix.load_json_object", + return_value={TEST_MXID: TEST_TOKEN}, + ) as mock: + yield mock + + +@pytest.fixture +def mock_allowed_path(): + """Allow using NamedTemporaryFile for mock image.""" + with patch("homeassistant.core.Config.is_allowed_path", return_value=True) as mock: + yield mock + + +@pytest.fixture +async def matrix_bot( + hass: HomeAssistant, mock_client, mock_save_json, mock_allowed_path +) -> MatrixBot: + """Set up Matrix and Notify component. + + The resulting MatrixBot will have a mocked _client. + """ + + assert await async_setup_component(hass, MATRIX_DOMAIN, MOCK_CONFIG_DATA) + assert await async_setup_component(hass, NOTIFY_DOMAIN, MOCK_CONFIG_DATA) + await hass.async_block_till_done() + assert isinstance(matrix_bot := hass.data[MATRIX_DOMAIN], MatrixBot) + + await hass.async_start() + + return matrix_bot + + +@pytest.fixture +def matrix_events(hass: HomeAssistant): + """Track event calls.""" + return async_capture_events(hass, MATRIX_DOMAIN) + + +@pytest.fixture +def command_events(hass: HomeAssistant): + """Track event calls.""" + return async_capture_events(hass, EVENT_MATRIX_COMMAND) + + +@pytest.fixture +def image_path(tmp_path): + """Provide the Path to a mock image.""" + image = Image.new("RGBA", size=(50, 50), color=(256, 0, 0)) + image_file = tempfile.NamedTemporaryFile(dir=tmp_path) + image.save(image_file, "PNG") + return image_file diff --git a/tests/components/matrix/test_join_rooms.py b/tests/components/matrix/test_join_rooms.py new file mode 100644 index 00000000000000..54856b91ac3541 --- /dev/null +++ b/tests/components/matrix/test_join_rooms.py @@ -0,0 +1,22 @@ +"""Test MatrixBot._join.""" + +from homeassistant.components.matrix import MatrixBot + +from tests.components.matrix.conftest import TEST_BAD_ROOM, TEST_JOINABLE_ROOMS + + +async def test_join(matrix_bot: MatrixBot, caplog): + """Test joining configured rooms.""" + + # Join configured rooms. + await matrix_bot._join_rooms() + for room_id in TEST_JOINABLE_ROOMS: + assert f"Joined or already in room '{room_id}'" in caplog.messages + + # Joining a disallowed room should not raise an exception. + matrix_bot._listening_rooms = [TEST_BAD_ROOM] + await matrix_bot._join_rooms() + assert ( + f"Could not join room '{TEST_BAD_ROOM}': JoinError: Not allowed to join this room." + in caplog.messages + ) diff --git a/tests/components/matrix/test_login.py b/tests/components/matrix/test_login.py new file mode 100644 index 00000000000000..8112d98fc8c65f --- /dev/null +++ b/tests/components/matrix/test_login.py @@ -0,0 +1,118 @@ +"""Test MatrixBot._login.""" + +from pydantic.dataclasses import dataclass +import pytest + +from homeassistant.components.matrix import MatrixBot +from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError + +from tests.components.matrix.conftest import ( + TEST_DEVICE_ID, + TEST_MXID, + TEST_PASSWORD, + TEST_TOKEN, +) + + +@dataclass +class LoginTestParameters: + """Dataclass of parameters representing the login parameters and expected result state.""" + + password: str + access_token: dict[str, str] + expected_login_state: bool + expected_caplog_messages: set[str] + expected_expection: type(Exception) | None = None + + +good_password_missing_token = LoginTestParameters( + password=TEST_PASSWORD, + access_token={}, + expected_login_state=True, + expected_caplog_messages={"Logging in using password"}, +) + +good_password_bad_token = LoginTestParameters( + password=TEST_PASSWORD, + access_token={TEST_MXID: "WrongToken"}, + expected_login_state=True, + expected_caplog_messages={ + "Restoring login from stored access token", + "Restoring login from access token failed: M_UNKNOWN_TOKEN, Invalid access token passed.", + "Logging in using password", + }, +) + +bad_password_good_access_token = LoginTestParameters( + password="WrongPassword", + access_token={TEST_MXID: TEST_TOKEN}, + expected_login_state=True, + expected_caplog_messages={ + "Restoring login from stored access token", + f"Successfully restored login from access token: user_id '{TEST_MXID}', device_id '{TEST_DEVICE_ID}'", + }, +) + +bad_password_bad_access_token = LoginTestParameters( + password="WrongPassword", + access_token={TEST_MXID: "WrongToken"}, + expected_login_state=False, + expected_caplog_messages={ + "Restoring login from stored access token", + "Restoring login from access token failed: M_UNKNOWN_TOKEN, Invalid access token passed.", + "Logging in using password", + "Login by password failed: status_code, LoginError", + }, + expected_expection=ConfigEntryAuthFailed, +) + +bad_password_missing_access_token = LoginTestParameters( + password="WrongPassword", + access_token={}, + expected_login_state=False, + expected_caplog_messages={ + "Logging in using password", + "Login by password failed: status_code, LoginError", + }, + expected_expection=ConfigEntryAuthFailed, +) + + +@pytest.mark.parametrize( + "params", + [ + good_password_missing_token, + good_password_bad_token, + bad_password_good_access_token, + bad_password_bad_access_token, + bad_password_missing_access_token, + ], +) +async def test_login( + matrix_bot: MatrixBot, caplog: pytest.LogCaptureFixture, params: LoginTestParameters +): + """Test logging in with the given parameters and expected state.""" + await matrix_bot._client.logout() + matrix_bot._password = params.password + matrix_bot._access_tokens = params.access_token + + if params.expected_expection: + with pytest.raises(params.expected_expection): + await matrix_bot._login() + else: + await matrix_bot._login() + assert matrix_bot._client.logged_in == params.expected_login_state + assert set(caplog.messages).issuperset(params.expected_caplog_messages) + + +async def test_get_auth_tokens(matrix_bot: MatrixBot, mock_load_json): + """Test loading access_tokens from a mocked file.""" + + # Test loading good tokens. + loaded_tokens = await matrix_bot._get_auth_tokens() + assert loaded_tokens == {TEST_MXID: TEST_TOKEN} + + # Test miscellaneous error from hass. + mock_load_json.side_effect = HomeAssistantError() + loaded_tokens = await matrix_bot._get_auth_tokens() + assert loaded_tokens == {} diff --git a/tests/components/matrix/test_matrix_bot.py b/tests/components/matrix/test_matrix_bot.py new file mode 100644 index 00000000000000..0b150a629fe23a --- /dev/null +++ b/tests/components/matrix/test_matrix_bot.py @@ -0,0 +1,88 @@ +"""Configure and test MatrixBot.""" +from nio import MatrixRoom, RoomMessageText + +from homeassistant.components.matrix import ( + DOMAIN as MATRIX_DOMAIN, + SERVICE_SEND_MESSAGE, + MatrixBot, +) +from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN +from homeassistant.core import HomeAssistant + +from .conftest import ( + MOCK_EXPRESSION_COMMANDS, + MOCK_WORD_COMMANDS, + TEST_JOINABLE_ROOMS, + TEST_NOTIFIER_NAME, +) + + +async def test_services(hass: HomeAssistant, matrix_bot: MatrixBot): + """Test hass/MatrixBot state.""" + + services = hass.services.async_services() + + # Verify that the matrix service is registered + assert (matrix_service := services.get(MATRIX_DOMAIN)) + assert SERVICE_SEND_MESSAGE in matrix_service + + # Verify that the matrix notifier is registered + assert (notify_service := services.get(NOTIFY_DOMAIN)) + assert TEST_NOTIFIER_NAME in notify_service + + +async def test_commands(hass, matrix_bot: MatrixBot, command_events): + """Test that the configured commands were parsed correctly.""" + + assert len(command_events) == 0 + + assert matrix_bot._word_commands == MOCK_WORD_COMMANDS + assert matrix_bot._expression_commands == MOCK_EXPRESSION_COMMANDS + + room_id = TEST_JOINABLE_ROOMS[0] + room = MatrixRoom(room_id=room_id, own_user_id=matrix_bot._mx_id) + + # Test single-word command. + word_command_message = RoomMessageText( + body="!WordTrigger arg1 arg2", + formatted_body=None, + format=None, + source={ + "event_id": "fake_event_id", + "sender": "@SomeUser:example.com", + "origin_server_ts": 123456789, + }, + ) + await matrix_bot._handle_room_message(room, word_command_message) + await hass.async_block_till_done() + assert len(command_events) == 1 + event = command_events.pop() + assert event.data == { + "command": "WordTriggerEventName", + "sender": "@SomeUser:example.com", + "room": room_id, + "args": ["arg1", "arg2"], + } + + # Test expression command. + room = MatrixRoom(room_id=room_id, own_user_id=matrix_bot._mx_id) + expression_command_message = RoomMessageText( + body="My name is FakeName", + formatted_body=None, + format=None, + source={ + "event_id": "fake_event_id", + "sender": "@SomeUser:example.com", + "origin_server_ts": 123456789, + }, + ) + await matrix_bot._handle_room_message(room, expression_command_message) + await hass.async_block_till_done() + assert len(command_events) == 1 + event = command_events.pop() + assert event.data == { + "command": "ExpressionTriggerEventName", + "sender": "@SomeUser:example.com", + "room": room_id, + "args": {"name": "FakeName"}, + } diff --git a/tests/components/matrix/test_send_message.py b/tests/components/matrix/test_send_message.py new file mode 100644 index 00000000000000..34964f2b09132a --- /dev/null +++ b/tests/components/matrix/test_send_message.py @@ -0,0 +1,71 @@ +"""Test the send_message service.""" + +from homeassistant.components.matrix import ( + ATTR_FORMAT, + ATTR_IMAGES, + DOMAIN as MATRIX_DOMAIN, + MatrixBot, +) +from homeassistant.components.matrix.const import FORMAT_HTML, SERVICE_SEND_MESSAGE +from homeassistant.components.notify import ATTR_DATA, ATTR_MESSAGE, ATTR_TARGET +from homeassistant.core import HomeAssistant + +from tests.components.matrix.conftest import TEST_BAD_ROOM, TEST_JOINABLE_ROOMS + + +async def test_send_message( + hass: HomeAssistant, matrix_bot: MatrixBot, image_path, matrix_events, caplog +): + """Test the send_message service.""" + assert len(matrix_events) == 0 + await matrix_bot._login() + + # Send a message without an attached image. + data = {ATTR_MESSAGE: "Test message", ATTR_TARGET: TEST_JOINABLE_ROOMS} + await hass.services.async_call( + MATRIX_DOMAIN, SERVICE_SEND_MESSAGE, data, blocking=True + ) + + for room_id in TEST_JOINABLE_ROOMS: + assert f"Message delivered to room '{room_id}'" in caplog.messages + + # Send an HTML message without an attached image. + data = { + ATTR_MESSAGE: "Test message", + ATTR_TARGET: TEST_JOINABLE_ROOMS, + ATTR_DATA: {ATTR_FORMAT: FORMAT_HTML}, + } + await hass.services.async_call( + MATRIX_DOMAIN, SERVICE_SEND_MESSAGE, data, blocking=True + ) + + for room_id in TEST_JOINABLE_ROOMS: + assert f"Message delivered to room '{room_id}'" in caplog.messages + + # Send a message with an attached image. + data[ATTR_DATA] = {ATTR_IMAGES: [image_path.name]} + await hass.services.async_call( + MATRIX_DOMAIN, SERVICE_SEND_MESSAGE, data, blocking=True + ) + + for room_id in TEST_JOINABLE_ROOMS: + assert f"Message delivered to room '{room_id}'" in caplog.messages + + +async def test_unsendable_message( + hass: HomeAssistant, matrix_bot: MatrixBot, matrix_events, caplog +): + """Test the send_message service with an invalid room.""" + assert len(matrix_events) == 0 + await matrix_bot._login() + + data = {ATTR_MESSAGE: "Test message", ATTR_TARGET: TEST_BAD_ROOM} + + await hass.services.async_call( + MATRIX_DOMAIN, SERVICE_SEND_MESSAGE, data, blocking=True + ) + + assert ( + f"Unable to deliver message to room '{TEST_BAD_ROOM}': ErrorResponse: Cannot send a message in this room." + in caplog.messages + ) diff --git a/tests/components/matter/test_event.py b/tests/components/matter/test_event.py index 0d5891a7778cfa..0aa9385a74c6cb 100644 --- a/tests/components/matter/test_event.py +++ b/tests/components/matter/test_event.py @@ -48,7 +48,7 @@ async def test_generic_switch_node( assert state.attributes[ATTR_EVENT_TYPES] == [ "initial_press", "short_release", - "long_press_ongoing", + "long_press", "long_release", "multi_press_ongoing", "multi_press_complete", @@ -111,7 +111,7 @@ async def test_generic_switch_multi_node( assert state_button_1.attributes[ATTR_EVENT_TYPES] == [ "initial_press", "short_release", - "long_press_ongoing", + "long_press", "long_release", ] # check button 2 diff --git a/tests/components/minecraft_server/const.py b/tests/components/minecraft_server/const.py new file mode 100644 index 00000000000000..3f635fbe333eb0 --- /dev/null +++ b/tests/components/minecraft_server/const.py @@ -0,0 +1,32 @@ +"""Constants for Minecraft Server integration tests.""" +from mcstatus.motd import Motd +from mcstatus.status_response import ( + JavaStatusPlayers, + JavaStatusResponse, + JavaStatusVersion, +) + +TEST_HOST = "mc.dummyserver.com" + +TEST_JAVA_STATUS_RESPONSE_RAW = { + "description": {"text": "Dummy Description"}, + "version": {"name": "Dummy Version", "protocol": 123}, + "players": { + "online": 3, + "max": 10, + "sample": [ + {"name": "Player 1", "id": "1"}, + {"name": "Player 2", "id": "2"}, + {"name": "Player 3", "id": "3"}, + ], + }, +} + +TEST_JAVA_STATUS_RESPONSE = JavaStatusResponse( + raw=TEST_JAVA_STATUS_RESPONSE_RAW, + players=JavaStatusPlayers.build(TEST_JAVA_STATUS_RESPONSE_RAW["players"]), + version=JavaStatusVersion.build(TEST_JAVA_STATUS_RESPONSE_RAW["version"]), + motd=Motd.parse(TEST_JAVA_STATUS_RESPONSE_RAW["description"], bedrock=False), + icon=None, + latency=5, +) diff --git a/tests/components/minecraft_server/test_config_flow.py b/tests/components/minecraft_server/test_config_flow.py index 3a201f15bf3121..463a78b468081a 100644 --- a/tests/components/minecraft_server/test_config_flow.py +++ b/tests/components/minecraft_server/test_config_flow.py @@ -1,9 +1,8 @@ -"""Test the Minecraft Server config flow.""" +"""Tests for the Minecraft Server config flow.""" from unittest.mock import AsyncMock, patch import aiodns -from mcstatus.status_response import JavaStatusResponse from homeassistant.components.minecraft_server.const import ( DEFAULT_NAME, @@ -11,11 +10,11 @@ DOMAIN, ) from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT +from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from tests.common import MockConfigEntry +from .const import TEST_HOST, TEST_JAVA_STATUS_RESPONSE class QueryMock: @@ -23,33 +22,19 @@ class QueryMock: def __init__(self) -> None: """Set up query result mock.""" - self.host = "mc.dummyserver.com" + self.host = TEST_HOST self.port = 23456 self.priority = 1 self.weight = 1 self.ttl = None -JAVA_STATUS_RESPONSE_RAW = { - "description": {"text": "Dummy Description"}, - "version": {"name": "Dummy Version", "protocol": 123}, - "players": { - "online": 3, - "max": 10, - "sample": [ - {"name": "Player 1", "id": "1"}, - {"name": "Player 2", "id": "2"}, - {"name": "Player 3", "id": "3"}, - ], - }, -} - USER_INPUT = { CONF_NAME: DEFAULT_NAME, - CONF_HOST: f"mc.dummyserver.com:{DEFAULT_PORT}", + CONF_HOST: f"{TEST_HOST}:{DEFAULT_PORT}", } -USER_INPUT_SRV = {CONF_NAME: DEFAULT_NAME, CONF_HOST: "dummyserver.com"} +USER_INPUT_SRV = {CONF_NAME: DEFAULT_NAME, CONF_HOST: TEST_HOST} USER_INPUT_IPV4 = { CONF_NAME: DEFAULT_NAME, @@ -63,12 +48,12 @@ def __init__(self) -> None: USER_INPUT_PORT_TOO_SMALL = { CONF_NAME: DEFAULT_NAME, - CONF_HOST: "mc.dummyserver.com:1023", + CONF_HOST: f"{TEST_HOST}:1023", } USER_INPUT_PORT_TOO_LARGE = { CONF_NAME: DEFAULT_NAME, - CONF_HOST: "mc.dummyserver.com:65536", + CONF_HOST: f"{TEST_HOST}:65536", } @@ -82,47 +67,6 @@ async def test_show_config_form(hass: HomeAssistant) -> None: assert result["step_id"] == "user" -async def test_invalid_ip(hass: HomeAssistant) -> None: - """Test error in case of an invalid IP address.""" - with patch("getmac.get_mac_address", return_value=None): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT_IPV4 - ) - - assert result["type"] == FlowResultType.FORM - assert result["errors"] == {"base": "invalid_ip"} - - -async def test_same_host(hass: HomeAssistant) -> None: - """Test abort in case of same host name.""" - with patch( - "aiodns.DNSResolver.query", - side_effect=aiodns.error.DNSError, - ), patch( - "mcstatus.server.JavaServer.async_status", - return_value=JavaStatusResponse( - None, None, None, None, JAVA_STATUS_RESPONSE_RAW, None - ), - ): - unique_id = "mc.dummyserver.com-25565" - config_data = { - CONF_NAME: DEFAULT_NAME, - CONF_HOST: "mc.dummyserver.com", - CONF_PORT: DEFAULT_PORT, - } - mock_config_entry = MockConfigEntry( - domain=DOMAIN, unique_id=unique_id, data=config_data - ) - mock_config_entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT - ) - - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "already_configured" - - async def test_port_too_small(hass: HomeAssistant) -> None: """Test error in case of a too small port.""" with patch( @@ -172,9 +116,7 @@ async def test_connection_succeeded_with_srv_record(hass: HomeAssistant) -> None side_effect=AsyncMock(return_value=[QueryMock()]), ), patch( "mcstatus.server.JavaServer.async_status", - return_value=JavaStatusResponse( - None, None, None, None, JAVA_STATUS_RESPONSE_RAW, None - ), + return_value=TEST_JAVA_STATUS_RESPONSE, ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT_SRV @@ -193,9 +135,7 @@ async def test_connection_succeeded_with_host(hass: HomeAssistant) -> None: side_effect=aiodns.error.DNSError, ), patch( "mcstatus.server.JavaServer.async_status", - return_value=JavaStatusResponse( - None, None, None, None, JAVA_STATUS_RESPONSE_RAW, None - ), + return_value=TEST_JAVA_STATUS_RESPONSE, ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT @@ -204,19 +144,17 @@ async def test_connection_succeeded_with_host(hass: HomeAssistant) -> None: assert result["type"] == FlowResultType.CREATE_ENTRY assert result["title"] == USER_INPUT[CONF_HOST] assert result["data"][CONF_NAME] == USER_INPUT[CONF_NAME] - assert result["data"][CONF_HOST] == "mc.dummyserver.com" + assert result["data"][CONF_HOST] == TEST_HOST async def test_connection_succeeded_with_ip4(hass: HomeAssistant) -> None: """Test config entry in case of a successful connection with an IPv4 address.""" - with patch("getmac.get_mac_address", return_value="01:23:45:67:89:ab"), patch( + with patch( "aiodns.DNSResolver.query", side_effect=aiodns.error.DNSError, ), patch( "mcstatus.server.JavaServer.async_status", - return_value=JavaStatusResponse( - None, None, None, None, JAVA_STATUS_RESPONSE_RAW, None - ), + return_value=TEST_JAVA_STATUS_RESPONSE, ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT_IPV4 @@ -230,14 +168,12 @@ async def test_connection_succeeded_with_ip4(hass: HomeAssistant) -> None: async def test_connection_succeeded_with_ip6(hass: HomeAssistant) -> None: """Test config entry in case of a successful connection with an IPv6 address.""" - with patch("getmac.get_mac_address", return_value="01:23:45:67:89:ab"), patch( + with patch( "aiodns.DNSResolver.query", side_effect=aiodns.error.DNSError, ), patch( "mcstatus.server.JavaServer.async_status", - return_value=JavaStatusResponse( - None, None, None, None, JAVA_STATUS_RESPONSE_RAW, None - ), + return_value=TEST_JAVA_STATUS_RESPONSE, ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT_IPV6 diff --git a/tests/components/minecraft_server/test_init.py b/tests/components/minecraft_server/test_init.py new file mode 100644 index 00000000000000..77b6901a0a2ce5 --- /dev/null +++ b/tests/components/minecraft_server/test_init.py @@ -0,0 +1,131 @@ +"""Tests for the Minecraft Server integration.""" +from unittest.mock import patch + +import aiodns + +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.minecraft_server.const import ( + DEFAULT_NAME, + DEFAULT_PORT, + DOMAIN, +) +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from .const import TEST_HOST, TEST_JAVA_STATUS_RESPONSE + +from tests.common import MockConfigEntry + +TEST_UNIQUE_ID = f"{TEST_HOST}-{DEFAULT_PORT}" + +SENSOR_KEYS = [ + {"v1": "Latency Time", "v2": "latency"}, + {"v1": "Players Max", "v2": "players_max"}, + {"v1": "Players Online", "v2": "players_online"}, + {"v1": "Protocol Version", "v2": "protocol_version"}, + {"v1": "Version", "v2": "version"}, + {"v1": "World Message", "v2": "motd"}, +] + +BINARY_SENSOR_KEYS = {"v1": "Status", "v2": "status"} + + +async def test_entry_migration_v1_to_v2(hass: HomeAssistant) -> None: + """Test entry migration from version 1 to 2.""" + + # Create mock config entry. + config_entry_v1 = MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_UNIQUE_ID, + data={ + CONF_NAME: DEFAULT_NAME, + CONF_HOST: TEST_HOST, + CONF_PORT: DEFAULT_PORT, + }, + version=1, + ) + config_entry_id = config_entry_v1.entry_id + config_entry_v1.add_to_hass(hass) + + # Create mock device entry. + device_registry = dr.async_get(hass) + device_entry_v1 = device_registry.async_get_or_create( + config_entry_id=config_entry_id, + identifiers={(DOMAIN, TEST_UNIQUE_ID)}, + ) + device_entry_id = device_entry_v1.id + assert device_entry_v1 + assert device_entry_id + + # Create mock sensor entity entries. + sensor_entity_id_key_mapping_list = [] + entity_registry = er.async_get(hass) + for sensor_key in SENSOR_KEYS: + entity_unique_id = f"{TEST_UNIQUE_ID}-{sensor_key['v1']}" + entity_entry_v1 = entity_registry.async_get_or_create( + SENSOR_DOMAIN, + DOMAIN, + unique_id=entity_unique_id, + config_entry=config_entry_v1, + device_id=device_entry_id, + ) + assert entity_entry_v1.unique_id == entity_unique_id + sensor_entity_id_key_mapping_list.append( + {"entity_id": entity_entry_v1.entity_id, "key": sensor_key["v2"]} + ) + + # Create mock binary sensor entity entry. + entity_unique_id = f"{TEST_UNIQUE_ID}-{BINARY_SENSOR_KEYS['v1']}" + entity_entry_v1 = entity_registry.async_get_or_create( + BINARY_SENSOR_DOMAIN, + DOMAIN, + unique_id=entity_unique_id, + config_entry=config_entry_v1, + device_id=device_entry_id, + ) + assert entity_entry_v1.unique_id == entity_unique_id + binary_sensor_entity_id_key_mapping = { + "entity_id": entity_entry_v1.entity_id, + "key": BINARY_SENSOR_KEYS["v2"], + } + + # Trigger migration. + with patch( + "aiodns.DNSResolver.query", + side_effect=aiodns.error.DNSError, + ), patch( + "mcstatus.server.JavaServer.async_status", + return_value=TEST_JAVA_STATUS_RESPONSE, + ): + assert await hass.config_entries.async_setup(config_entry_id) + await hass.async_block_till_done() + + # Test migrated config entry. + config_entry_v2 = hass.config_entries.async_get_entry(config_entry_id) + assert config_entry_v2.unique_id is None + assert config_entry_v2.data == { + CONF_NAME: DEFAULT_NAME, + CONF_HOST: TEST_HOST, + CONF_PORT: DEFAULT_PORT, + } + assert config_entry_v2.version == 2 + + # Test migrated device entry. + device_entry_v2 = device_registry.async_get(device_entry_id) + assert device_entry_v2.identifiers == {(DOMAIN, config_entry_id)} + + # Test migrated sensor entity entries. + for mapping in sensor_entity_id_key_mapping_list: + entity_entry_v2 = entity_registry.async_get(mapping["entity_id"]) + assert entity_entry_v2.unique_id == f"{config_entry_id}-{mapping['key']}" + + # Test migrated binary sensor entity entry. + entity_entry_v2 = entity_registry.async_get( + binary_sensor_entity_id_key_mapping["entity_id"] + ) + assert ( + entity_entry_v2.unique_id + == f"{config_entry_id}-{binary_sensor_entity_id_key_mapping['key']}" + ) diff --git a/tests/components/mobile_app/conftest.py b/tests/components/mobile_app/conftest.py index e7c9ad4995a4f8..f69912f176c713 100644 --- a/tests/components/mobile_app/conftest.py +++ b/tests/components/mobile_app/conftest.py @@ -10,18 +10,16 @@ @pytest.fixture -async def create_registrations(hass, authed_api_client): +async def create_registrations(hass, webhook_client): """Return two new registrations.""" await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) - enc_reg = await authed_api_client.post( - "/api/mobile_app/registrations", json=REGISTER - ) + enc_reg = await webhook_client.post("/api/mobile_app/registrations", json=REGISTER) assert enc_reg.status == HTTPStatus.CREATED enc_reg_json = await enc_reg.json() - clear_reg = await authed_api_client.post( + clear_reg = await webhook_client.post( "/api/mobile_app/registrations", json=REGISTER_CLEARTEXT ) @@ -34,11 +32,11 @@ async def create_registrations(hass, authed_api_client): @pytest.fixture -async def push_registration(hass, authed_api_client): +async def push_registration(hass, webhook_client): """Return registration with push notifications enabled.""" await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) - enc_reg = await authed_api_client.post( + enc_reg = await webhook_client.post( "/api/mobile_app/registrations", json={ **REGISTER, @@ -54,17 +52,7 @@ async def push_registration(hass, authed_api_client): @pytest.fixture -async def webhook_client(hass, authed_api_client, aiohttp_client): - """mobile_app mock client.""" - # We pass in the authed_api_client server instance because - # it is used inside create_registrations and just passing in - # the app instance would cause the server to start twice, - # which caused deprecation warnings to be printed. - return await aiohttp_client(authed_api_client.server) - - -@pytest.fixture -async def authed_api_client(hass, hass_client): +async def webhook_client(hass, hass_client): """Provide an authenticated client for mobile_app to use.""" await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) await hass.async_block_till_done() diff --git a/tests/components/mobile_app/test_webhook.py b/tests/components/mobile_app/test_webhook.py index 4faf48e2118a62..9f6aec404e2b37 100644 --- a/tests/components/mobile_app/test_webhook.py +++ b/tests/components/mobile_app/test_webhook.py @@ -196,9 +196,9 @@ def store_event(event): assert events[0].data["hello"] == "yo world" -async def test_webhook_update_registration(webhook_client, authed_api_client) -> None: +async def test_webhook_update_registration(webhook_client) -> None: """Test that a we can update an existing registration via webhook.""" - register_resp = await authed_api_client.post( + register_resp = await webhook_client.post( "/api/mobile_app/registrations", json=REGISTER_CLEARTEXT ) diff --git a/tests/components/modbus/conftest.py b/tests/components/modbus/conftest.py index 23d3ee522bb6e7..d7e4556f746220 100644 --- a/tests/components/modbus/conftest.py +++ b/tests/components/modbus/conftest.py @@ -10,7 +10,7 @@ import pytest from homeassistant.components.modbus.const import MODBUS_DOMAIN as DOMAIN, TCP -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, CONF_SLAVE, CONF_TYPE +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, CONF_TYPE from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -32,6 +32,11 @@ def __init__(self, register_words): """Init.""" self.registers = register_words self.bits = register_words + self.value = register_words + + def isError(self): + """Set error state.""" + return False @pytest.fixture(name="mock_pymodbus") @@ -87,9 +92,6 @@ async def mock_modbus_fixture( for key in conf: if config_addon: conf[key][0].update(config_addon) - for entity in conf[key]: - if CONF_SLAVE not in entity: - entity[CONF_SLAVE] = 0 caplog.set_level(logging.WARNING) config = { DOMAIN: [ @@ -134,11 +136,15 @@ async def mock_pymodbus_exception_fixture(hass, do_exception, mock_modbus): @pytest.fixture(name="mock_pymodbus_return") async def mock_pymodbus_return_fixture(hass, register_words, mock_modbus): """Trigger update call with time_changed event.""" - read_result = ReadResult(register_words) + read_result = ReadResult(register_words) if register_words else None mock_modbus.read_coils.return_value = read_result mock_modbus.read_discrete_inputs.return_value = read_result mock_modbus.read_input_registers.return_value = read_result mock_modbus.read_holding_registers.return_value = read_result + mock_modbus.write_register.return_value = read_result + mock_modbus.write_registers.return_value = read_result + mock_modbus.write_coil.return_value = read_result + mock_modbus.write_coils.return_value = read_result @pytest.fixture(name="mock_do_cycle") @@ -149,7 +155,7 @@ async def mock_do_cycle_fixture( mock_pymodbus_return, ) -> FrozenDateTimeFactory: """Trigger update call with time_changed event.""" - freezer.tick(timedelta(seconds=90)) + freezer.tick(timedelta(seconds=1)) async_fire_time_changed(hass) await hass.async_block_till_done() return freezer diff --git a/tests/components/modbus/test_binary_sensor.py b/tests/components/modbus/test_binary_sensor.py index 1e413fcc7640fe..2069aa23b8fc0f 100644 --- a/tests/components/modbus/test_binary_sensor.py +++ b/tests/components/modbus/test_binary_sensor.py @@ -8,9 +8,11 @@ CALL_TYPE_DISCRETE, CALL_TYPE_REGISTER_HOLDING, CALL_TYPE_REGISTER_INPUT, + CONF_DEVICE_ADDRESS, CONF_INPUT_TYPE, CONF_LAZY_ERROR, CONF_SLAVE_COUNT, + CONF_VIRTUAL_COUNT, MODBUS_DOMAIN, ) from homeassistant.const import ( @@ -59,6 +61,18 @@ } ] }, + { + CONF_BINARY_SENSORS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 51, + CONF_DEVICE_ADDRESS: 10, + CONF_INPUT_TYPE: CALL_TYPE_DISCRETE, + CONF_DEVICE_CLASS: "door", + CONF_LAZY_ERROR: 10, + } + ] + }, { CONF_BINARY_SENSORS: [ { @@ -69,6 +83,16 @@ } ] }, + { + CONF_BINARY_SENSORS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 51, + CONF_DEVICE_ADDRESS: 10, + CONF_INPUT_TYPE: CALL_TYPE_REGISTER_INPUT, + } + ] + }, ], ) async def test_config_binary_sensor(hass: HomeAssistant, mock_modbus) -> None: @@ -265,7 +289,7 @@ async def test_service_binary_sensor_update( CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 51, CONF_SCAN_INTERVAL: 0, - CONF_SLAVE_COUNT: 1, + CONF_VIRTUAL_COUNT: 1, } ] }, @@ -294,9 +318,18 @@ async def test_restore_state_binary_sensor( } ] }, + { + CONF_BINARY_SENSORS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 52, + CONF_VIRTUAL_COUNT: 3, + } + ] + }, ], ) -async def test_config_slave_binary_sensor(hass: HomeAssistant, mock_modbus) -> None: +async def test_config_virtual_binary_sensor(hass: HomeAssistant, mock_modbus) -> None: """Run config test for binary sensor.""" assert SENSOR_DOMAIN in hass.config.components @@ -355,33 +388,63 @@ async def test_config_slave_binary_sensor(hass: HomeAssistant, mock_modbus) -> N STATE_OFF, [STATE_OFF], ), + ( + {CONF_VIRTUAL_COUNT: 1, CONF_UNIQUE_ID: SLAVE_UNIQUE_ID}, + [False] * 8, + STATE_OFF, + [STATE_OFF], + ), ( {CONF_SLAVE_COUNT: 1, CONF_UNIQUE_ID: SLAVE_UNIQUE_ID}, [True] + [False] * 7, STATE_ON, [STATE_OFF], ), + ( + {CONF_VIRTUAL_COUNT: 1, CONF_UNIQUE_ID: SLAVE_UNIQUE_ID}, + [True] + [False] * 7, + STATE_ON, + [STATE_OFF], + ), ( {CONF_SLAVE_COUNT: 1, CONF_UNIQUE_ID: SLAVE_UNIQUE_ID}, [False, True] + [False] * 6, STATE_OFF, [STATE_ON], ), + ( + {CONF_VIRTUAL_COUNT: 1, CONF_UNIQUE_ID: SLAVE_UNIQUE_ID}, + [False, True] + [False] * 6, + STATE_OFF, + [STATE_ON], + ), ( {CONF_SLAVE_COUNT: 7, CONF_UNIQUE_ID: SLAVE_UNIQUE_ID}, [True, False] * 4, STATE_ON, [STATE_OFF, STATE_ON] * 3 + [STATE_OFF], ), + ( + {CONF_VIRTUAL_COUNT: 7, CONF_UNIQUE_ID: SLAVE_UNIQUE_ID}, + [True, False] * 4, + STATE_ON, + [STATE_OFF, STATE_ON] * 3 + [STATE_OFF], + ), ( {CONF_SLAVE_COUNT: 31, CONF_UNIQUE_ID: SLAVE_UNIQUE_ID}, [True, False] * 16, STATE_ON, [STATE_OFF, STATE_ON] * 15 + [STATE_OFF], ), + ( + {CONF_VIRTUAL_COUNT: 31, CONF_UNIQUE_ID: SLAVE_UNIQUE_ID}, + [True, False] * 16, + STATE_ON, + [STATE_OFF, STATE_ON] * 15 + [STATE_OFF], + ), ], ) -async def test_slave_binary_sensor( +async def test_virtual_binary_sensor( hass: HomeAssistant, expected, slaves, mock_do_cycle ) -> None: """Run test for given config.""" diff --git a/tests/components/modbus/test_climate.py b/tests/components/modbus/test_climate.py index 4ab78df0c81987..f2de0177c74f03 100644 --- a/tests/components/modbus/test_climate.py +++ b/tests/components/modbus/test_climate.py @@ -11,6 +11,7 @@ from homeassistant.components.modbus.const import ( CONF_CLIMATES, CONF_DATA_TYPE, + CONF_DEVICE_ADDRESS, CONF_HVAC_MODE_AUTO, CONF_HVAC_MODE_COOL, CONF_HVAC_MODE_DRY, @@ -57,6 +58,16 @@ } ], }, + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_DEVICE_ADDRESS: 10, + } + ], + }, { CONF_CLIMATES: [ { diff --git a/tests/components/modbus/test_cover.py b/tests/components/modbus/test_cover.py index 66e4537d67eca3..b91b38b1f701e4 100644 --- a/tests/components/modbus/test_cover.py +++ b/tests/components/modbus/test_cover.py @@ -7,6 +7,7 @@ from homeassistant.components.modbus.const import ( CALL_TYPE_COIL, CALL_TYPE_REGISTER_HOLDING, + CONF_DEVICE_ADDRESS, CONF_INPUT_TYPE, CONF_LAZY_ERROR, CONF_STATE_CLOSED, @@ -62,6 +63,18 @@ } ] }, + { + CONF_COVERS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 1234, + CONF_INPUT_TYPE: CALL_TYPE_REGISTER_HOLDING, + CONF_DEVICE_ADDRESS: 10, + CONF_SCAN_INTERVAL: 20, + CONF_LAZY_ERROR: 10, + } + ] + }, ], ) async def test_config_cover(hass: HomeAssistant, mock_modbus) -> None: diff --git a/tests/components/modbus/test_fan.py b/tests/components/modbus/test_fan.py index 2d2cc83162d09e..932e07b2d1a0ed 100644 --- a/tests/components/modbus/test_fan.py +++ b/tests/components/modbus/test_fan.py @@ -8,6 +8,7 @@ CALL_TYPE_DISCRETE, CALL_TYPE_REGISTER_HOLDING, CALL_TYPE_REGISTER_INPUT, + CONF_DEVICE_ADDRESS, CONF_FANS, CONF_INPUT_TYPE, CONF_LAZY_ERROR, @@ -75,6 +76,24 @@ } ] }, + { + CONF_FANS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 1234, + CONF_DEVICE_ADDRESS: 1, + CONF_COMMAND_OFF: 0x00, + CONF_COMMAND_ON: 0x01, + CONF_LAZY_ERROR: 10, + CONF_VERIFY: { + CONF_INPUT_TYPE: CALL_TYPE_REGISTER_HOLDING, + CONF_ADDRESS: 1235, + CONF_STATE_OFF: 0, + CONF_STATE_ON: 1, + }, + } + ] + }, { CONF_FANS: [ { @@ -242,7 +261,10 @@ async def test_restore_state_fan( ], ) async def test_fan_service_turn( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_modbus + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mock_modbus, + mock_pymodbus_return, ) -> None: """Run test for service turn_on/turn_off.""" diff --git a/tests/components/modbus/test_init.py b/tests/components/modbus/test_init.py index 6f88a4b7399c68..e66115f24d9c6b 100644 --- a/tests/components/modbus/test_init.py +++ b/tests/components/modbus/test_init.py @@ -40,16 +40,20 @@ CALL_TYPE_WRITE_REGISTERS, CONF_BAUDRATE, CONF_BYTESIZE, + CONF_CLOSE_COMM_ON_ERROR, CONF_DATA_TYPE, + CONF_DEVICE_ADDRESS, CONF_INPUT_TYPE, CONF_MSG_WAIT, CONF_PARITY, + CONF_RETRY_ON_EMPTY, CONF_SLAVE_COUNT, CONF_STOPBITS, CONF_SWAP, CONF_SWAP_BYTE, CONF_SWAP_WORD, CONF_SWAP_WORD_BYTE, + CONF_VIRTUAL_COUNT, DEFAULT_SCAN_INTERVAL, MODBUS_DOMAIN as DOMAIN, RTUOVERTCP, @@ -263,11 +267,23 @@ async def test_ok_struct_validator(do_config) -> None: CONF_STRUCTURE: ">f", CONF_SLAVE_COUNT: 5, }, + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_COUNT: 2, + CONF_DATA_TYPE: DataType.CUSTOM, + CONF_STRUCTURE: ">f", + CONF_VIRTUAL_COUNT: 5, + }, { CONF_NAME: TEST_ENTITY_NAME, CONF_DATA_TYPE: DataType.STRING, CONF_SLAVE_COUNT: 2, }, + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_DATA_TYPE: DataType.STRING, + CONF_VIRTUAL_COUNT: 2, + }, { CONF_NAME: TEST_ENTITY_NAME, CONF_DATA_TYPE: DataType.INT16, @@ -279,6 +295,12 @@ async def test_ok_struct_validator(do_config) -> None: CONF_SLAVE_COUNT: 2, CONF_DATA_TYPE: DataType.INT32, }, + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_COUNT: 2, + CONF_VIRTUAL_COUNT: 2, + CONF_DATA_TYPE: DataType.INT32, + }, { CONF_NAME: TEST_ENTITY_NAME, CONF_DATA_TYPE: DataType.INT16, @@ -393,6 +415,18 @@ async def test_duplicate_entity_validator(do_config) -> None: @pytest.mark.parametrize( "do_config", [ + { + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, + CONF_CLOSE_COMM_ON_ERROR: True, + }, + { + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, + CONF_RETRY_ON_EMPTY: True, + }, { CONF_TYPE: TCP, CONF_HOST: TEST_MODBUS_HOST, @@ -498,6 +532,20 @@ async def test_duplicate_entity_validator(do_config) -> None: } ], }, + { + # Special test for scan_interval validator with scan_interval: 0 + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, + CONF_SENSORS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 117, + CONF_DEVICE_ADDRESS: 0, + CONF_SCAN_INTERVAL: 0, + } + ], + }, ], ) async def test_config_modbus( @@ -566,17 +614,17 @@ async def test_config_modbus( ], ) @pytest.mark.parametrize( - "do_unit", + "do_slave", [ - ATTR_UNIT, ATTR_SLAVE, + ATTR_UNIT, ], ) async def test_pb_service_write( hass: HomeAssistant, do_write, do_return, - do_unit, + do_slave, caplog: pytest.LogCaptureFixture, mock_modbus_with_pymodbus, ) -> None: @@ -591,7 +639,7 @@ async def test_pb_service_write( data = { ATTR_HUB: TEST_MODBUS_NAME, - do_unit: 17, + do_slave: 17, ATTR_ADDRESS: 16, do_write[DATA]: do_write[VALUE], } @@ -884,7 +932,7 @@ async def test_stop_restart( caplog.set_level(logging.INFO) entity_id = f"{SENSOR_DOMAIN}.{TEST_ENTITY_NAME}".replace(" ", "_") - assert hass.states.get(entity_id).state == STATE_UNKNOWN + assert hass.states.get(entity_id).state in (STATE_UNKNOWN, STATE_UNAVAILABLE) hass.states.async_set(entity_id, 17) await hass.async_block_till_done() assert hass.states.get(entity_id).state == "17" @@ -932,7 +980,7 @@ async def test_write_no_client(hass: HomeAssistant, mock_modbus) -> None: data = { ATTR_HUB: TEST_MODBUS_NAME, - ATTR_UNIT: 17, + ATTR_SLAVE: 17, ATTR_ADDRESS: 16, ATTR_STATE: True, } diff --git a/tests/components/modbus/test_light.py b/tests/components/modbus/test_light.py index 46763b3b3a2306..1d6963aaa12eaf 100644 --- a/tests/components/modbus/test_light.py +++ b/tests/components/modbus/test_light.py @@ -8,6 +8,7 @@ CALL_TYPE_DISCRETE, CALL_TYPE_REGISTER_HOLDING, CALL_TYPE_REGISTER_INPUT, + CONF_DEVICE_ADDRESS, CONF_INPUT_TYPE, CONF_LAZY_ERROR, CONF_STATE_OFF, @@ -75,6 +76,23 @@ } ] }, + { + CONF_LIGHTS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 1234, + CONF_DEVICE_ADDRESS: 1, + CONF_COMMAND_OFF: 0x00, + CONF_COMMAND_ON: 0x01, + CONF_VERIFY: { + CONF_INPUT_TYPE: CALL_TYPE_REGISTER_HOLDING, + CONF_ADDRESS: 1235, + CONF_STATE_OFF: 0, + CONF_STATE_ON: 1, + }, + } + ] + }, { CONF_LIGHTS: [ { @@ -242,7 +260,10 @@ async def test_restore_state_light( ], ) async def test_light_service_turn( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_modbus + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mock_modbus, + mock_pymodbus_return, ) -> None: """Run test for service turn_on/turn_off.""" diff --git a/tests/components/modbus/test_sensor.py b/tests/components/modbus/test_sensor.py index f72371ed42e81a..0f79a125c86a2f 100644 --- a/tests/components/modbus/test_sensor.py +++ b/tests/components/modbus/test_sensor.py @@ -1,4 +1,6 @@ """The tests for the Modbus sensor component.""" +import struct + from freezegun.api import FrozenDateTimeFactory import pytest @@ -6,6 +8,7 @@ CALL_TYPE_REGISTER_HOLDING, CALL_TYPE_REGISTER_INPUT, CONF_DATA_TYPE, + CONF_DEVICE_ADDRESS, CONF_INPUT_TYPE, CONF_LAZY_ERROR, CONF_MAX_VALUE, @@ -19,6 +22,7 @@ CONF_SWAP_NONE, CONF_SWAP_WORD, CONF_SWAP_WORD_BYTE, + CONF_VIRTUAL_COUNT, CONF_ZERO_SUPPRESS, MODBUS_DOMAIN, DataType, @@ -83,6 +87,23 @@ } ] }, + { + CONF_SENSORS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 51, + CONF_DEVICE_ADDRESS: 10, + CONF_DATA_TYPE: DataType.INT16, + CONF_PRECISION: 0, + CONF_SCALE: 1, + CONF_OFFSET: 0, + CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, + CONF_LAZY_ERROR: 10, + CONF_INPUT_TYPE: CALL_TYPE_REGISTER_HOLDING, + CONF_DEVICE_CLASS: "battery", + } + ] + }, { CONF_SENSORS: [ { @@ -148,6 +169,16 @@ } ] }, + { + CONF_SENSORS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 51, + CONF_DATA_TYPE: DataType.INT32, + CONF_VIRTUAL_COUNT: 5, + } + ] + }, ], ) async def test_config_sensor(hass: HomeAssistant, mock_modbus) -> None: @@ -187,7 +218,7 @@ async def test_config_sensor(hass: HomeAssistant, mock_modbus) -> None: }, ] }, - "Structure request 16 bytes, but 2 registers have a size of 4 bytes", + f"{TEST_ENTITY_NAME}: Size of structure is 16 bytes but `{CONF_COUNT}: 2` is 4 bytes", ), ( { @@ -212,12 +243,11 @@ async def test_config_sensor(hass: HomeAssistant, mock_modbus) -> None: CONF_ADDRESS: 1234, CONF_DATA_TYPE: DataType.CUSTOM, CONF_COUNT: 4, - CONF_SWAP: CONF_SWAP_NONE, CONF_STRUCTURE: "", }, ] }, - f"Error in sensor {TEST_ENTITY_NAME}. The `structure` field cannot be empty", + f"{TEST_ENTITY_NAME}: `{CONF_STRUCTURE}` missing or empty, demanded with `{CONF_DATA_TYPE}: {DataType.CUSTOM}`", ), ( { @@ -227,12 +257,11 @@ async def test_config_sensor(hass: HomeAssistant, mock_modbus) -> None: CONF_ADDRESS: 1234, CONF_DATA_TYPE: DataType.CUSTOM, CONF_COUNT: 4, - CONF_SWAP: CONF_SWAP_NONE, CONF_STRUCTURE: "1s", }, ] }, - "Structure request 1 bytes, but 4 registers have a size of 8 bytes", + f"{TEST_ENTITY_NAME}: Size of structure is 1 bytes but `{CONF_COUNT}: 4` is 8 bytes", ), ( { @@ -247,7 +276,7 @@ async def test_config_sensor(hass: HomeAssistant, mock_modbus) -> None: }, ] }, - f"{TEST_ENTITY_NAME}: `structure` illegal with `swap`", + f"{TEST_ENTITY_NAME}: `{CONF_SWAP}:{CONF_SWAP_WORD}` cannot be combined with `{CONF_DATA_TYPE}: {DataType.CUSTOM}`", ), ], ) @@ -267,7 +296,6 @@ async def test_config_wrong_struct_sensor( { CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 51, - CONF_SCAN_INTERVAL: 1, }, ], }, @@ -381,6 +409,17 @@ async def test_config_wrong_struct_sensor( False, "-1985229329", ), + ( + { + CONF_DATA_TYPE: DataType.INT32, + CONF_SCALE: 1, + CONF_OFFSET: 0, + CONF_PRECISION: 0, + }, + [0x89AB], + False, + STATE_UNAVAILABLE, + ), ( { CONF_DATA_TYPE: DataType.UINT32, @@ -599,6 +638,38 @@ async def test_config_wrong_struct_sensor( False, "1.23", ), + ( + { + CONF_DATA_TYPE: DataType.INT32, + CONF_SCALE: 10, + CONF_OFFSET: 0, + CONF_PRECISION: 0, + }, + [0x00AB, 0xCDEF], + False, + "112593750", + ), + ( + { + CONF_DATA_TYPE: DataType.INT32, + CONF_SCALE: 0.01, + CONF_OFFSET: 0, + CONF_PRECISION: 2, + }, + [0x00AB, 0xCDEF], + False, + "112593.75", + ), + ( + { + CONF_DATA_TYPE: DataType.INT32, + CONF_SCALE: 0.01, + CONF_OFFSET: 0, + }, + [0x00AB, 0xCDEF], + False, + "112593.75", + ), ], ) async def test_all_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> None: @@ -625,6 +696,36 @@ async def test_all_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> None: @pytest.mark.parametrize( ("config_addon", "register_words", "do_exception", "expected"), [ + ( + { + CONF_SLAVE_COUNT: 1, + CONF_UNIQUE_ID: SLAVE_UNIQUE_ID, + CONF_DATA_TYPE: DataType.FLOAT32, + }, + [ + 0x5102, + 0x0304, + int.from_bytes(struct.pack(">f", float("nan"))[0:2]), + int.from_bytes(struct.pack(">f", float("nan"))[2:4]), + ], + False, + ["34899771392", "0"], + ), + ( + { + CONF_VIRTUAL_COUNT: 1, + CONF_UNIQUE_ID: SLAVE_UNIQUE_ID, + CONF_DATA_TYPE: DataType.FLOAT32, + }, + [ + 0x5102, + 0x0304, + int.from_bytes(struct.pack(">f", float("nan"))[0:2]), + int.from_bytes(struct.pack(">f", float("nan"))[2:4]), + ], + False, + ["34899771392", "0"], + ), ( { CONF_SLAVE_COUNT: 0, @@ -634,6 +735,15 @@ async def test_all_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> None: False, ["16909060"], ), + ( + { + CONF_VIRTUAL_COUNT: 0, + CONF_UNIQUE_ID: SLAVE_UNIQUE_ID, + }, + [0x0102, 0x0304], + False, + ["16909060"], + ), ( { CONF_SLAVE_COUNT: 1, @@ -643,6 +753,24 @@ async def test_all_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> None: False, ["16909060", "67305985"], ), + ( + { + CONF_VIRTUAL_COUNT: 1, + CONF_UNIQUE_ID: SLAVE_UNIQUE_ID, + }, + [0x0102, 0x0304, 0x0403, 0x0201], + False, + ["16909060", "67305985"], + ), + ( + { + CONF_VIRTUAL_COUNT: 2, + CONF_UNIQUE_ID: SLAVE_UNIQUE_ID, + }, + [0x0102, 0x0304, 0x0403, 0x0201, 0x0403], + False, + [STATE_UNAVAILABLE, STATE_UNKNOWN, STATE_UNKNOWN], + ), ( { CONF_SLAVE_COUNT: 3, @@ -666,6 +794,29 @@ async def test_all_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> None: "219025152", ], ), + ( + { + CONF_VIRTUAL_COUNT: 3, + CONF_UNIQUE_ID: SLAVE_UNIQUE_ID, + }, + [ + 0x0102, + 0x0304, + 0x0506, + 0x0708, + 0x090A, + 0x0B0C, + 0x0D0E, + 0x0F00, + ], + False, + [ + "16909060", + "84281096", + "151653132", + "219025152", + ], + ), ( { CONF_SLAVE_COUNT: 1, @@ -675,6 +826,15 @@ async def test_all_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> None: True, [STATE_UNAVAILABLE, STATE_UNKNOWN], ), + ( + { + CONF_VIRTUAL_COUNT: 1, + CONF_UNIQUE_ID: SLAVE_UNIQUE_ID, + }, + [0x0102, 0x0304, 0x0403, 0x0201], + True, + [STATE_UNAVAILABLE, STATE_UNKNOWN], + ), ( { CONF_SLAVE_COUNT: 1, @@ -684,9 +844,18 @@ async def test_all_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> None: False, [STATE_UNAVAILABLE, STATE_UNKNOWN], ), + ( + { + CONF_VIRTUAL_COUNT: 1, + CONF_UNIQUE_ID: SLAVE_UNIQUE_ID, + }, + [], + False, + [STATE_UNAVAILABLE, STATE_UNKNOWN], + ), ], ) -async def test_slave_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> None: +async def test_virtual_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> None: """Run test for sensor.""" entity_registry = er.async_get(hass) for i in range(0, len(expected)): @@ -710,7 +879,6 @@ async def test_slave_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> Non CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 51, CONF_INPUT_TYPE: CALL_TYPE_REGISTER_HOLDING, - CONF_SCAN_INTERVAL: 1, }, ], }, @@ -721,7 +889,7 @@ async def test_slave_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> Non [ ( { - CONF_SLAVE_COUNT: 0, + CONF_VIRTUAL_COUNT: 0, CONF_UNIQUE_ID: SLAVE_UNIQUE_ID, CONF_SWAP: CONF_SWAP_BYTE, CONF_DATA_TYPE: DataType.UINT16, @@ -732,7 +900,7 @@ async def test_slave_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> Non ), ( { - CONF_SLAVE_COUNT: 0, + CONF_VIRTUAL_COUNT: 0, CONF_UNIQUE_ID: SLAVE_UNIQUE_ID, CONF_SWAP: CONF_SWAP_WORD, CONF_DATA_TYPE: DataType.UINT32, @@ -743,7 +911,7 @@ async def test_slave_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> Non ), ( { - CONF_SLAVE_COUNT: 0, + CONF_VIRTUAL_COUNT: 0, CONF_UNIQUE_ID: SLAVE_UNIQUE_ID, CONF_SWAP: CONF_SWAP_WORD, CONF_DATA_TYPE: DataType.UINT64, @@ -754,7 +922,7 @@ async def test_slave_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> Non ), ( { - CONF_SLAVE_COUNT: 1, + CONF_VIRTUAL_COUNT: 1, CONF_UNIQUE_ID: SLAVE_UNIQUE_ID, CONF_DATA_TYPE: DataType.UINT16, CONF_SWAP: CONF_SWAP_BYTE, @@ -765,7 +933,7 @@ async def test_slave_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> Non ), ( { - CONF_SLAVE_COUNT: 1, + CONF_VIRTUAL_COUNT: 1, CONF_UNIQUE_ID: SLAVE_UNIQUE_ID, CONF_DATA_TYPE: DataType.UINT32, CONF_SWAP: CONF_SWAP_WORD, @@ -776,7 +944,7 @@ async def test_slave_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> Non ), ( { - CONF_SLAVE_COUNT: 1, + CONF_VIRTUAL_COUNT: 1, CONF_UNIQUE_ID: SLAVE_UNIQUE_ID, CONF_DATA_TYPE: DataType.UINT64, CONF_SWAP: CONF_SWAP_WORD, @@ -787,7 +955,7 @@ async def test_slave_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> Non ), ( { - CONF_SLAVE_COUNT: 3, + CONF_VIRTUAL_COUNT: 3, CONF_UNIQUE_ID: SLAVE_UNIQUE_ID, CONF_DATA_TYPE: DataType.UINT16, CONF_SWAP: CONF_SWAP_BYTE, @@ -798,7 +966,7 @@ async def test_slave_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> Non ), ( { - CONF_SLAVE_COUNT: 3, + CONF_VIRTUAL_COUNT: 3, CONF_UNIQUE_ID: SLAVE_UNIQUE_ID, CONF_DATA_TYPE: DataType.UINT32, CONF_SWAP: CONF_SWAP_WORD, @@ -823,7 +991,7 @@ async def test_slave_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> Non ), ( { - CONF_SLAVE_COUNT: 3, + CONF_VIRTUAL_COUNT: 3, CONF_UNIQUE_ID: SLAVE_UNIQUE_ID, CONF_DATA_TYPE: DataType.UINT64, CONF_SWAP: CONF_SWAP_WORD, @@ -856,7 +1024,9 @@ async def test_slave_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> Non ), ], ) -async def test_slave_swap_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> None: +async def test_virtual_swap_sensor( + hass: HomeAssistant, mock_do_cycle, expected +) -> None: """Run test for sensor.""" for i in range(0, len(expected)): entity_id = f"{SENSOR_DOMAIN}.{TEST_ENTITY_NAME}".replace(" ", "_") @@ -902,6 +1072,65 @@ async def test_wrong_unpack(hass: HomeAssistant, mock_do_cycle) -> None: assert hass.states.get(ENTITY_ID).state == STATE_UNAVAILABLE +@pytest.mark.parametrize( + "do_config", + [ + { + CONF_SENSORS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 51, + CONF_SCAN_INTERVAL: 1, + }, + ], + }, + ], +) +@pytest.mark.parametrize( + ("config_addon", "register_words", "expected"), + [ + ( + { + CONF_DATA_TYPE: DataType.FLOAT32, + }, + [ + int.from_bytes(struct.pack(">f", float("nan"))[0:2]), + int.from_bytes(struct.pack(">f", float("nan"))[2:4]), + ], + STATE_UNAVAILABLE, + ), + ( + { + CONF_DATA_TYPE: DataType.FLOAT32, + }, + [0x6E61, 0x6E00], + STATE_UNAVAILABLE, + ), + ( + { + CONF_DATA_TYPE: DataType.CUSTOM, + CONF_COUNT: 2, + CONF_STRUCTURE: "4s", + }, + [0x6E61, 0x6E00], + STATE_UNAVAILABLE, + ), + ( + { + CONF_DATA_TYPE: DataType.CUSTOM, + CONF_COUNT: 2, + CONF_STRUCTURE: "4s", + }, + [0x6161, 0x6100], + "aaa\x00", + ), + ], +) +async def test_unpack_ok(hass: HomeAssistant, mock_do_cycle, expected) -> None: + """Run test for sensor.""" + assert hass.states.get(ENTITY_ID).state == expected + + @pytest.mark.parametrize( "do_config", [ @@ -918,27 +1147,23 @@ async def test_wrong_unpack(hass: HomeAssistant, mock_do_cycle) -> None: ], ) @pytest.mark.parametrize( - ("register_words", "do_exception", "start_expect", "end_expect"), + ("register_words", "do_exception"), [ ( [0x8000], True, - "17", - STATE_UNAVAILABLE, ), ], ) async def test_lazy_error_sensor( - hass: HomeAssistant, mock_do_cycle: FrozenDateTimeFactory, start_expect, end_expect + hass: HomeAssistant, mock_do_cycle: FrozenDateTimeFactory ) -> None: """Run test for sensor.""" hass.states.async_set(ENTITY_ID, 17) await hass.async_block_till_done() - assert hass.states.get(ENTITY_ID).state == start_expect - await do_next_cycle(hass, mock_do_cycle, 11) - assert hass.states.get(ENTITY_ID).state == start_expect - await do_next_cycle(hass, mock_do_cycle, 11) - assert hass.states.get(ENTITY_ID).state == end_expect + assert hass.states.get(ENTITY_ID).state == "17" + await do_next_cycle(hass, mock_do_cycle, 5) + assert hass.states.get(ENTITY_ID).state == STATE_UNAVAILABLE @pytest.mark.parametrize( @@ -965,10 +1190,35 @@ async def test_lazy_error_sensor( CONF_DATA_TYPE: DataType.CUSTOM, CONF_STRUCTURE: ">4f", }, - # floats: 7.931250095367432, 10.600000381469727, + # floats: nan, 10.600000381469727, # 1.000879611487865e-28, 10.566553115844727 - [0x40FD, 0xCCCD, 0x4129, 0x999A, 0x10FD, 0xC0CD, 0x4129, 0x109A], - "7.93,10.60,0.00,10.57", + [ + int.from_bytes(struct.pack(">f", float("nan"))[0:2]), + int.from_bytes(struct.pack(">f", float("nan"))[2:4]), + 0x4129, + 0x999A, + 0x10FD, + 0xC0CD, + 0x4129, + 0x109A, + ], + "0,10.60,0.00,10.57", + ), + ( + { + CONF_COUNT: 4, + CONF_DATA_TYPE: DataType.CUSTOM, + CONF_STRUCTURE: ">2i", + CONF_NAN_VALUE: 0x0000000F, + }, + # int: nan, 10, + [ + 0x0000, + 0x000F, + 0x0000, + 0x000A, + ], + "0,10", ), ( { @@ -988,6 +1238,18 @@ async def test_lazy_error_sensor( [0x0101], "257", ), + ( + { + CONF_COUNT: 8, + CONF_PRECISION: 2, + CONF_DATA_TYPE: DataType.CUSTOM, + CONF_STRUCTURE: ">4f", + }, + # floats: 7.931250095367432, 10.600000381469727, + # 1.000879611487865e-28, 10.566553115844727 + [0x40FD, 0xCCCD, 0x4129, 0x999A, 0x10FD, 0xC0CD, 0x4129, 0x109A], + "7.93,10.60,0.00,10.57", + ), ], ) async def test_struct_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> None: @@ -1003,7 +1265,6 @@ async def test_struct_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> No { CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 201, - CONF_SCAN_INTERVAL: 1, }, ], }, @@ -1014,7 +1275,6 @@ async def test_struct_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> No [ ( { - CONF_COUNT: 1, CONF_SWAP: CONF_SWAP_NONE, CONF_DATA_TYPE: DataType.UINT16, }, @@ -1023,7 +1283,6 @@ async def test_struct_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> No ), ( { - CONF_COUNT: 1, CONF_SWAP: CONF_SWAP_BYTE, CONF_DATA_TYPE: DataType.UINT16, }, @@ -1032,7 +1291,6 @@ async def test_struct_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> No ), ( { - CONF_COUNT: 2, CONF_SWAP: CONF_SWAP_NONE, CONF_DATA_TYPE: DataType.UINT32, }, @@ -1041,7 +1299,6 @@ async def test_struct_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> No ), ( { - CONF_COUNT: 2, CONF_SWAP: CONF_SWAP_BYTE, CONF_DATA_TYPE: DataType.UINT32, }, @@ -1050,7 +1307,6 @@ async def test_struct_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> No ), ( { - CONF_COUNT: 2, CONF_SWAP: CONF_SWAP_WORD, CONF_DATA_TYPE: DataType.UINT32, }, @@ -1059,7 +1315,6 @@ async def test_struct_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> No ), ( { - CONF_COUNT: 2, CONF_SWAP: CONF_SWAP_WORD_BYTE, CONF_DATA_TYPE: DataType.UINT32, }, @@ -1100,7 +1355,7 @@ async def mock_restore(hass): CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 51, CONF_SCAN_INTERVAL: 0, - CONF_SLAVE_COUNT: 1, + CONF_VIRTUAL_COUNT: 1, } ] }, diff --git a/tests/components/modbus/test_switch.py b/tests/components/modbus/test_switch.py index dce4588d606859..0eb40d2c08299e 100644 --- a/tests/components/modbus/test_switch.py +++ b/tests/components/modbus/test_switch.py @@ -11,6 +11,7 @@ CALL_TYPE_DISCRETE, CALL_TYPE_REGISTER_HOLDING, CALL_TYPE_REGISTER_INPUT, + CONF_DEVICE_ADDRESS, CONF_INPUT_TYPE, CONF_LAZY_ERROR, CONF_STATE_OFF, @@ -85,6 +86,24 @@ } ] }, + { + CONF_SWITCHES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 1234, + CONF_DEVICE_ADDRESS: 1, + CONF_COMMAND_OFF: 0x00, + CONF_COMMAND_ON: 0x01, + CONF_DEVICE_CLASS: "switch", + CONF_VERIFY: { + CONF_INPUT_TYPE: CALL_TYPE_REGISTER_HOLDING, + CONF_ADDRESS: 1235, + CONF_STATE_OFF: 0, + CONF_STATE_ON: 1, + }, + } + ] + }, { CONF_SWITCHES: [ { @@ -250,7 +269,7 @@ async def test_lazy_error_switch( @pytest.mark.parametrize( "mock_test_state", - [(State(ENTITY_ID, STATE_ON),)], + [(State(ENTITY_ID, STATE_ON),), (State(ENTITY_ID, STATE_OFF),)], indirect=True, ) @pytest.mark.parametrize( @@ -297,7 +316,10 @@ async def test_restore_state_switch( ], ) async def test_switch_service_turn( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_modbus + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mock_modbus, + mock_pymodbus_return, ) -> None: """Run test for service turn_on/turn_off.""" assert MODBUS_DOMAIN in hass.config.components @@ -388,7 +410,9 @@ async def test_service_switch_update(hass: HomeAssistant, mock_modbus, mock_ha) }, ], ) -async def test_delay_switch(hass: HomeAssistant, mock_modbus) -> None: +async def test_delay_switch( + hass: HomeAssistant, mock_modbus, mock_pymodbus_return +) -> None: """Run test for switch verify delay.""" mock_modbus.read_holding_registers.return_value = ReadResult([0x01]) now = dt_util.utcnow() diff --git a/tests/components/modern_forms/test_config_flow.py b/tests/components/modern_forms/test_config_flow.py index 540a8fef93de37..49bac6a5bb0b0c 100644 --- a/tests/components/modern_forms/test_config_flow.py +++ b/tests/components/modern_forms/test_config_flow.py @@ -1,4 +1,5 @@ """Tests for the Modern Forms config flow.""" +from ipaddress import ip_address from unittest.mock import MagicMock, patch import aiohttp @@ -65,8 +66,8 @@ async def test_full_zeroconf_flow_implementation( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.123", - addresses=["192.168.1.123"], + ip_address=ip_address("192.168.1.123"), + ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", name="mock_name", port=None, @@ -134,8 +135,8 @@ async def test_zeroconf_connection_error( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.123", - addresses=["192.168.1.123"], + ip_address=ip_address("192.168.1.123"), + ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", name="mock_name", port=None, @@ -166,8 +167,8 @@ async def test_zeroconf_confirm_connection_error( CONF_NAME: "test", }, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.123", - addresses=["192.168.1.123"], + ip_address=ip_address("192.168.1.123"), + ip_addresses=[ip_address("192.168.1.123")], hostname="example.com.", name="mock_name", port=None, @@ -236,8 +237,8 @@ async def test_zeroconf_with_mac_device_exists_abort( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.123", - addresses=["192.168.1.123"], + ip_address=ip_address("192.168.1.123"), + ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", name="mock_name", port=None, diff --git a/tests/components/mqtt/conftest.py b/tests/components/mqtt/conftest.py index ebe86c1f1dfb7d..91ece381f6d463 100644 --- a/tests/components/mqtt/conftest.py +++ b/tests/components/mqtt/conftest.py @@ -1,5 +1,9 @@ """Test fixtures for mqtt component.""" +from collections.abc import Generator +from random import getrandbits +from unittest.mock import patch + import pytest from tests.components.light.conftest import mock_light_profiles # noqa: F401 @@ -8,3 +12,20 @@ @pytest.fixture(autouse=True) def patch_hass_config(mock_hass_config: None) -> None: """Patch configuration.yaml.""" + + +@pytest.fixture +def temp_dir_prefix() -> str: + """Set an alternate temp dir prefix.""" + return "test" + + +@pytest.fixture +def mock_temp_dir(temp_dir_prefix: str) -> Generator[None, None, str]: + """Mock the certificate temp directory.""" + with patch( + # Patch temp dir name to avoid tests fail running in parallel + "homeassistant.components.mqtt.util.TEMP_DIR_NAME", + f"home-assistant-mqtt-{temp_dir_prefix}-{getrandbits(10):03x}", + ) as mocked_temp_dir: + yield mocked_temp_dir diff --git a/tests/components/mqtt/test_binary_sensor.py b/tests/components/mqtt/test_binary_sensor.py index 28bf5f558cbcfa..ea9c8072290a23 100644 --- a/tests/components/mqtt/test_binary_sensor.py +++ b/tests/components/mqtt/test_binary_sensor.py @@ -6,6 +6,7 @@ from typing import Any from unittest.mock import patch +from freezegun import freeze_time from freezegun.api import FrozenDateTimeFactory import pytest @@ -46,6 +47,7 @@ help_test_reloadable, help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, + help_test_skipped_async_ha_write_state, help_test_unique_id, help_test_unload_config_entry_with_platform, help_test_update_with_json_attrs_bad_json, @@ -146,57 +148,64 @@ async def expires_helper(hass: HomeAssistant) -> None: """Run the basic expiry code.""" realnow = dt_util.utcnow() now = datetime(realnow.year + 1, 1, 1, 1, tzinfo=dt_util.UTC) - with patch(("homeassistant.helpers.event.dt_util.utcnow"), return_value=now): + with freeze_time(now) as freezer: + freezer.move_to(now) async_fire_time_changed(hass, now) async_fire_mqtt_message(hass, "test-topic", "ON") await hass.async_block_till_done() - # Value was set correctly. - state = hass.states.get("binary_sensor.test") - assert state.state == STATE_ON + # Value was set correctly. + state = hass.states.get("binary_sensor.test") + assert state.state == STATE_ON - # Time jump +3s - now = now + timedelta(seconds=3) - async_fire_time_changed(hass, now) - await hass.async_block_till_done() + # Time jump +3s + now += timedelta(seconds=3) + freezer.move_to(now) + async_fire_time_changed(hass, now) + await hass.async_block_till_done() - # Value is not yet expired - state = hass.states.get("binary_sensor.test") - assert state.state == STATE_ON + # Value is not yet expired + state = hass.states.get("binary_sensor.test") + assert state.state == STATE_ON - # Next message resets timer - with patch(("homeassistant.helpers.event.dt_util.utcnow"), return_value=now): + # Next message resets timer + # Time jump 0.5s + now += timedelta(seconds=0.5) + freezer.move_to(now) async_fire_time_changed(hass, now) async_fire_mqtt_message(hass, "test-topic", "OFF") await hass.async_block_till_done() - # Value was updated correctly. - state = hass.states.get("binary_sensor.test") - assert state.state == STATE_OFF + # Value was updated correctly. + state = hass.states.get("binary_sensor.test") + assert state.state == STATE_OFF - # Time jump +3s - now = now + timedelta(seconds=3) - async_fire_time_changed(hass, now) - await hass.async_block_till_done() + # Time jump +3s + now += timedelta(seconds=3) + freezer.move_to(now) + async_fire_time_changed(hass, now) + await hass.async_block_till_done() - # Value is not yet expired - state = hass.states.get("binary_sensor.test") - assert state.state == STATE_OFF + # Value is not yet expired + state = hass.states.get("binary_sensor.test") + assert state.state == STATE_OFF - # Time jump +2s - now = now + timedelta(seconds=2) - async_fire_time_changed(hass, now) - await hass.async_block_till_done() + # Time jump +2s + now += timedelta(seconds=2) + freezer.move_to(now) + async_fire_time_changed(hass, now) + await hass.async_block_till_done() - # Value is expired now - state = hass.states.get("binary_sensor.test") - assert state.state == STATE_UNAVAILABLE + # Value is expired now + state = hass.states.get("binary_sensor.test") + assert state.state == STATE_UNAVAILABLE async def test_expiration_on_discovery_and_discovery_update_of_binary_sensor( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, + freezer: FrozenDateTimeFactory, ) -> None: """Test that binary_sensor with expire_after set behaves correctly on discovery and discovery update.""" await mqtt_mock_entry() @@ -212,31 +221,28 @@ async def test_expiration_on_discovery_and_discovery_update_of_binary_sensor( # Set time and publish config message to create binary_sensor via discovery with 4 s expiry realnow = dt_util.utcnow() now = datetime(realnow.year + 1, 1, 1, 1, tzinfo=dt_util.UTC) - with patch(("homeassistant.helpers.event.dt_util.utcnow"), return_value=now): - async_fire_time_changed(hass, now) - async_fire_mqtt_message( - hass, "homeassistant/binary_sensor/bla/config", config_msg - ) - await hass.async_block_till_done() + freezer.move_to(now) + async_fire_time_changed(hass, now) + async_fire_mqtt_message(hass, "homeassistant/binary_sensor/bla/config", config_msg) + await hass.async_block_till_done() # Test that binary_sensor is not available state = hass.states.get("binary_sensor.test") assert state.state == STATE_UNAVAILABLE # Publish state message - with patch(("homeassistant.helpers.event.dt_util.utcnow"), return_value=now): - async_fire_mqtt_message(hass, "test-topic", "ON") - await hass.async_block_till_done() + async_fire_mqtt_message(hass, "test-topic", "ON") + await hass.async_block_till_done() # Test that binary_sensor has correct state state = hass.states.get("binary_sensor.test") assert state.state == STATE_ON # Advance +3 seconds - now = now + timedelta(seconds=3) - with patch(("homeassistant.helpers.event.dt_util.utcnow"), return_value=now): - async_fire_time_changed(hass, now) - await hass.async_block_till_done() + now += timedelta(seconds=3) + freezer.move_to(now) + async_fire_time_changed(hass, now) + await hass.async_block_till_done() # binary_sensor is not yet expired state = hass.states.get("binary_sensor.test") @@ -255,21 +261,18 @@ async def test_expiration_on_discovery_and_discovery_update_of_binary_sensor( assert state.state == STATE_ON # Add +2 seconds - now = now + timedelta(seconds=2) - with patch(("homeassistant.helpers.event.dt_util.utcnow"), return_value=now): - async_fire_time_changed(hass, now) - await hass.async_block_till_done() + now += timedelta(seconds=2) + freezer.move_to(now) + async_fire_time_changed(hass, now) + await hass.async_block_till_done() # Test that binary_sensor has expired state = hass.states.get("binary_sensor.test") assert state.state == STATE_UNAVAILABLE # Resend config message to update discovery - with patch(("homeassistant.helpers.event.dt_util.utcnow"), return_value=now): - async_fire_mqtt_message( - hass, "homeassistant/binary_sensor/bla/config", config_msg - ) - await hass.async_block_till_done() + async_fire_mqtt_message(hass, "homeassistant/binary_sensor/bla/config", config_msg) + await hass.async_block_till_done() # Test that binary_sensor is still expired state = hass.states.get("binary_sensor.test") @@ -1246,3 +1249,38 @@ async def test_entity_name( await help_test_entity_name( hass, mqtt_mock_entry, domain, config, expected_friendly_name, device_class ) + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + binary_sensor.DOMAIN, + DEFAULT_CONFIG, + ( + { + "availability_topic": "availability-topic", + "json_attributes_topic": "json-attributes-topic", + }, + ), + ) + ], +) +@pytest.mark.parametrize( + ("topic", "payload1", "payload2"), + [ + ("test-topic", "ON", "OFF"), + ("availability-topic", "online", "offline"), + ("json-attributes-topic", '{"attr1": "val1"}', '{"attr1": "val2"}'), + ], +) +async def test_skipped_async_ha_write_state( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + topic: str, + payload1: str, + payload2: str, +) -> None: + """Test a write state command is only called when there is change.""" + await mqtt_mock_entry() + await help_test_skipped_async_ha_write_state(hass, topic, payload1, payload2) diff --git a/tests/components/mqtt/test_common.py b/tests/components/mqtt/test_common.py index 9aa88c2d7ba072..64bece5369e5d2 100644 --- a/tests/components/mqtt/test_common.py +++ b/tests/components/mqtt/test_common.py @@ -1925,3 +1925,28 @@ async def help_test_discovery_setup( await hass.async_block_till_done() state = hass.states.get(f"{domain}.{name}") assert state and state.state is not None + + +async def help_test_skipped_async_ha_write_state( + hass: HomeAssistant, topic: str, payload1: str, payload2: str +) -> None: + """Test entity.async_ha_write_state is only called on changes.""" + with patch( + "homeassistant.components.mqtt.mixins.MqttEntity.async_write_ha_state" + ) as mock_async_ha_write_state: + assert len(mock_async_ha_write_state.mock_calls) == 0 + async_fire_mqtt_message(hass, topic, payload1) + await hass.async_block_till_done() + assert len(mock_async_ha_write_state.mock_calls) == 1 + + async_fire_mqtt_message(hass, topic, payload1) + await hass.async_block_till_done() + assert len(mock_async_ha_write_state.mock_calls) == 1 + + async_fire_mqtt_message(hass, topic, payload2) + await hass.async_block_till_done() + assert len(mock_async_ha_write_state.mock_calls) == 2 + + async_fire_mqtt_message(hass, topic, payload2) + await hass.async_block_till_done() + assert len(mock_async_ha_write_state.mock_calls) == 2 diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index f0681a537da557..c2a7e0065ce04f 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -2,7 +2,6 @@ from collections.abc import Generator, Iterator from contextlib import contextmanager from pathlib import Path -from random import getrandbits from ssl import SSLError from typing import Any from unittest.mock import AsyncMock, MagicMock, patch @@ -131,7 +130,9 @@ def mock_try_connection_time_out() -> Generator[MagicMock, None, None]: @pytest.fixture -def mock_process_uploaded_file(tmp_path: Path) -> Generator[MagicMock, None, None]: +def mock_process_uploaded_file( + tmp_path: Path, mock_temp_dir: str +) -> Generator[MagicMock, None, None]: """Mock upload certificate files.""" file_id_ca = str(uuid4()) file_id_cert = str(uuid4()) @@ -159,11 +160,7 @@ def _mock_process_uploaded_file( with patch( "homeassistant.components.mqtt.config_flow.process_uploaded_file", side_effect=_mock_process_uploaded_file, - ) as mock_upload, patch( - # Patch temp dir name to avoid tests fail running in parallel - "homeassistant.components.mqtt.util.TEMP_DIR_NAME", - "home-assistant-mqtt" + f"-{getrandbits(10):03x}", - ): + ) as mock_upload: mock_upload.file_id = { mqtt.CONF_CERTIFICATE: file_id_ca, mqtt.CONF_CLIENT_CERT: file_id_cert, diff --git a/tests/components/mqtt/test_mixins.py b/tests/components/mqtt/test_mixins.py index 0647721b4d019c..1ca9bf07d72a6b 100644 --- a/tests/components/mqtt/test_mixins.py +++ b/tests/components/mqtt/test_mixins.py @@ -7,6 +7,7 @@ from homeassistant.components import mqtt, sensor from homeassistant.components.mqtt.sensor import DEFAULT_NAME as DEFAULT_SENSOR_NAME from homeassistant.const import ( + ATTR_FRIENDLY_NAME, EVENT_HOMEASSISTANT_STARTED, EVENT_STATE_CHANGED, Platform, @@ -324,7 +325,6 @@ async def test_default_entity_and_device_name( This is a test helper for the _setup_common_attributes_from_config mixin. """ - # mqtt_mock = await mqtt_mock_entry() events = async_capture_events(hass, ir.EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED) hass.state = CoreState.starting @@ -352,3 +352,61 @@ async def test_default_entity_and_device_name( # Assert that an issues ware registered assert len(events) == issue_events + + +@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.BINARY_SENSOR]) +async def test_name_attribute_is_set_or_not( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test frendly name with device_class set. + + This is a test helper for the _setup_common_attributes_from_config mixin. + """ + await mqtt_mock_entry() + async_fire_mqtt_message( + hass, + "homeassistant/binary_sensor/bla/config", + '{ "name": "Gate", "state_topic": "test-topic", "device_class": "door", ' + '"object_id": "gate",' + '"device": {"identifiers": "very_unique", "name": "xyz_door_sensor"}' + "}", + ) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.gate") + + assert state is not None + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Gate" + + # Remove the name in a discovery update + async_fire_mqtt_message( + hass, + "homeassistant/binary_sensor/bla/config", + '{ "state_topic": "test-topic", "device_class": "door", ' + '"object_id": "gate",' + '"device": {"identifiers": "very_unique", "name": "xyz_door_sensor"}' + "}", + ) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.gate") + + assert state is not None + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Door" + + # Set the name to `null` in a discovery update + async_fire_mqtt_message( + hass, + "homeassistant/binary_sensor/bla/config", + '{ "name": null, "state_topic": "test-topic", "device_class": "door", ' + '"object_id": "gate",' + '"device": {"identifiers": "very_unique", "name": "xyz_door_sensor"}' + "}", + ) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.gate") + + assert state is not None + assert state.attributes.get(ATTR_FRIENDLY_NAME) is None diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index 043c8d539b6f80..bc75492a03e27c 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -6,6 +6,7 @@ from typing import Any from unittest.mock import MagicMock, patch +from freezegun import freeze_time from freezegun.api import FrozenDateTimeFactory import pytest @@ -59,6 +60,7 @@ help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_setting_blocked_attribute_via_mqtt_json_message, + help_test_skipped_async_ha_write_state, help_test_unique_id, help_test_unload_config_entry_with_platform, help_test_update_with_json_attrs_bad_json, @@ -360,51 +362,56 @@ async def expires_helper(hass: HomeAssistant) -> None: """Run the basic expiry code.""" realnow = dt_util.utcnow() now = datetime(realnow.year + 1, 1, 1, 1, tzinfo=dt_util.UTC) - with patch(("homeassistant.helpers.event.dt_util.utcnow"), return_value=now): + with freeze_time(now) as freezer: + freezer.move_to(now) async_fire_time_changed(hass, now) async_fire_mqtt_message(hass, "test-topic", "100") await hass.async_block_till_done() - # Value was set correctly. - state = hass.states.get("sensor.test") - assert state.state == "100" + # Value was set correctly. + state = hass.states.get("sensor.test") + assert state.state == "100" - # Time jump +3s - now = now + timedelta(seconds=3) - async_fire_time_changed(hass, now) - await hass.async_block_till_done() + # Time jump +3s + now += timedelta(seconds=3) + freezer.move_to(now) + async_fire_time_changed(hass, now) + await hass.async_block_till_done() - # Value is not yet expired - state = hass.states.get("sensor.test") - assert state.state == "100" + # Value is not yet expired + state = hass.states.get("sensor.test") + assert state.state == "100" - # Next message resets timer - with patch(("homeassistant.helpers.event.dt_util.utcnow"), return_value=now): + # Next message resets timer + now += timedelta(seconds=0.5) + freezer.move_to(now) async_fire_time_changed(hass, now) async_fire_mqtt_message(hass, "test-topic", "101") await hass.async_block_till_done() - # Value was updated correctly. - state = hass.states.get("sensor.test") - assert state.state == "101" + # Value was updated correctly. + state = hass.states.get("sensor.test") + assert state.state == "101" - # Time jump +3s - now = now + timedelta(seconds=3) - async_fire_time_changed(hass, now) - await hass.async_block_till_done() + # Time jump +3s + now += timedelta(seconds=3) + freezer.move_to(now) + async_fire_time_changed(hass, now) + await hass.async_block_till_done() - # Value is not yet expired - state = hass.states.get("sensor.test") - assert state.state == "101" + # Value is not yet expired + state = hass.states.get("sensor.test") + assert state.state == "101" - # Time jump +2s - now = now + timedelta(seconds=2) - async_fire_time_changed(hass, now) - await hass.async_block_till_done() + # Time jump +2s + now += timedelta(seconds=2) + freezer.move_to(now) + async_fire_time_changed(hass, now) + await hass.async_block_till_done() - # Value is expired now - state = hass.states.get("sensor.test") - assert state.state == STATE_UNAVAILABLE + # Value is expired now + state = hass.states.get("sensor.test") + assert state.state == STATE_UNAVAILABLE @pytest.mark.parametrize( @@ -1431,3 +1438,45 @@ async def test_entity_name( await help_test_entity_name( hass, mqtt_mock_entry, domain, config, expected_friendly_name, device_class ) + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + sensor.DOMAIN, + DEFAULT_CONFIG, + ( + { + "availability_topic": "availability-topic", + "json_attributes_topic": "json-attributes-topic", + "value_template": "{{ value_json.state }}", + "last_reset_value_template": "{{ value_json.last_reset }}", + }, + ), + ) + ], +) +@pytest.mark.parametrize( + ("topic", "payload1", "payload2"), + [ + ("test-topic", '{"state":"val1"}', '{"state":"val2"}'), + ( + "test-topic", + '{"last_reset":"2023-09-15 15:11:03"}', + '{"last_reset":"2023-09-16 15:11:02"}', + ), + ("availability-topic", "online", "offline"), + ("json-attributes-topic", '{"attr1": "val1"}', '{"attr1": "val2"}'), + ], +) +async def test_skipped_async_ha_write_state( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + topic: str, + payload1: str, + payload2: str, +) -> None: + """Test a write state command is only called when there is change.""" + await mqtt_mock_entry() + await help_test_skipped_async_ha_write_state(hass, topic, payload1, payload2) diff --git a/tests/components/mqtt/test_util.py b/tests/components/mqtt/test_util.py index e93a5e376bbb96..941072bc224e18 100644 --- a/tests/components/mqtt/test_util.py +++ b/tests/components/mqtt/test_util.py @@ -1,7 +1,9 @@ """Test MQTT utils.""" from collections.abc import Callable +from pathlib import Path from random import getrandbits +import tempfile from unittest.mock import patch import pytest @@ -14,17 +16,6 @@ from tests.typing import MqttMockHAClient, MqttMockPahoClient -@pytest.fixture(autouse=True) -def mock_temp_dir(): - """Mock the certificate temp directory.""" - with patch( - # Patch temp dir name to avoid tests fail running in parallel - "homeassistant.components.mqtt.util.TEMP_DIR_NAME", - "home-assistant-mqtt" + f"-{getrandbits(10):03x}", - ) as mocked_temp_dir: - yield mocked_temp_dir - - @pytest.mark.parametrize( ("option", "content", "file_created"), [ @@ -34,31 +25,50 @@ def mock_temp_dir(): (mqtt.CONF_CLIENT_KEY, "### PRIVATE KEY ###", True), ], ) +@pytest.mark.parametrize("temp_dir_prefix", ["create-test"]) async def test_async_create_certificate_temp_files( - hass: HomeAssistant, mock_temp_dir, option, content, file_created + hass: HomeAssistant, + mock_temp_dir: str, + option: str, + content: str, + file_created: bool, ) -> None: """Test creating and reading and recovery certificate files.""" config = {option: content} - await mqtt.util.async_create_certificate_temp_files(hass, config) - file_path = mqtt.util.get_file_path(option) + temp_dir = Path(tempfile.gettempdir()) / mock_temp_dir + + # Create old file to be able to assert it is removed with auto option + def _ensure_old_file_exists() -> None: + if not temp_dir.exists(): + temp_dir.mkdir(0o700) + temp_file = temp_dir / option + with open(temp_file, "wb") as old_file: + old_file.write(b"old content") + old_file.close() + + await hass.async_add_executor_job(_ensure_old_file_exists) + await mqtt.util.async_create_certificate_temp_files(hass, config) + file_path = await hass.async_add_executor_job(mqtt.util.get_file_path, option) assert bool(file_path) is file_created assert ( - mqtt.util.migrate_certificate_file_to_content(file_path or content) == content + await hass.async_add_executor_job( + mqtt.util.migrate_certificate_file_to_content, file_path or content + ) + == content ) # Make sure certificate temp files are recovered - if file_path: - # Overwrite content of file (except for auto option) - file = open(file_path, "wb") - file.write(b"invalid") - file.close() + await hass.async_add_executor_job(_ensure_old_file_exists) await mqtt.util.async_create_certificate_temp_files(hass, config) - file_path2 = mqtt.util.get_file_path(option) + file_path2 = await hass.async_add_executor_job(mqtt.util.get_file_path, option) assert bool(file_path2) is file_created assert ( - mqtt.util.migrate_certificate_file_to_content(file_path2 or content) == content + await hass.async_add_executor_job( + mqtt.util.migrate_certificate_file_to_content, file_path2 or content + ) + == content ) assert file_path == file_path2 @@ -71,6 +81,26 @@ async def test_reading_non_exitisting_certificate_file() -> None: ) +@pytest.mark.parametrize("temp_dir_prefix", "unknown") +async def test_return_default_get_file_path( + hass: HomeAssistant, mock_temp_dir: str +) -> None: + """Test get_file_path returns default.""" + + def _get_file_path(file_path: Path) -> bool: + return ( + not file_path.exists() + and mqtt.util.get_file_path("some_option", "mydefault") == "mydefault" + ) + + with patch( + "homeassistant.components.mqtt.util.TEMP_DIR_NAME", + f"home-assistant-mqtt-other-{getrandbits(10):03x}", + ) as mock_temp_dir: + tempdir = Path(tempfile.gettempdir()) / mock_temp_dir + assert await hass.async_add_executor_job(_get_file_path, tempdir) + + @patch("homeassistant.components.mqtt.PLATFORMS", []) async def test_waiting_for_client_not_loaded( hass: HomeAssistant, diff --git a/tests/components/nam/test_config_flow.py b/tests/components/nam/test_config_flow.py index 78a96e148cec13..a8f1245d9d63a3 100644 --- a/tests/components/nam/test_config_flow.py +++ b/tests/components/nam/test_config_flow.py @@ -1,5 +1,6 @@ """Define tests for the Nettigo Air Monitor config flow.""" import asyncio +from ipaddress import ip_address from unittest.mock import patch from nettigo_air_monitor import ApiError, AuthFailedError, CannotGetMacError @@ -14,8 +15,8 @@ from tests.common import MockConfigEntry DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( - host="10.10.2.3", - addresses=["10.10.2.3"], + ip_address=ip_address("10.10.2.3"), + ip_addresses=[ip_address("10.10.2.3")], hostname="mock_hostname", name="mock_name", port=None, diff --git a/tests/components/nanoleaf/test_config_flow.py b/tests/components/nanoleaf/test_config_flow.py index 9a7f4a2bc508fc..2fce4e55bbc9a9 100644 --- a/tests/components/nanoleaf/test_config_flow.py +++ b/tests/components/nanoleaf/test_config_flow.py @@ -1,6 +1,7 @@ """Test the Nanoleaf config flow.""" from __future__ import annotations +from ipaddress import ip_address from unittest.mock import AsyncMock, MagicMock, patch from aionanoleaf import InvalidToken, Unauthorized, Unavailable @@ -237,8 +238,8 @@ async def test_discovery_link_unavailable( DOMAIN, context={"source": source}, data=zeroconf.ZeroconfServiceInfo( - host=TEST_HOST, - addresses=[TEST_HOST], + ip_address=ip_address(TEST_HOST), + ip_addresses=[ip_address(TEST_HOST)], hostname="mock_hostname", name=f"{TEST_NAME}.{type_in_discovery_info}", port=None, @@ -372,8 +373,8 @@ async def test_import_discovery_integration( DOMAIN, context={"source": source}, data=zeroconf.ZeroconfServiceInfo( - host=TEST_HOST, - addresses=[TEST_HOST], + ip_address=ip_address(TEST_HOST), + ip_addresses=[ip_address(TEST_HOST)], hostname="mock_hostname", name=f"{TEST_NAME}.{type_in_discovery}", port=None, diff --git a/tests/components/nest/test_sensor_sdm.py b/tests/components/nest/test_sensor.py similarity index 100% rename from tests/components/nest/test_sensor_sdm.py rename to tests/components/nest/test_sensor.py diff --git a/tests/components/netatmo/test_config_flow.py b/tests/components/netatmo/test_config_flow.py index a89fff13cdd4ba..56d319b1631726 100644 --- a/tests/components/netatmo/test_config_flow.py +++ b/tests/components/netatmo/test_config_flow.py @@ -1,4 +1,5 @@ """Test the Netatmo config flow.""" +from ipaddress import ip_address from unittest.mock import patch from pyatmo.const import ALL_SCOPES @@ -44,8 +45,8 @@ async def test_abort_if_existing_entry(hass: HomeAssistant) -> None: "netatmo", context={"source": config_entries.SOURCE_HOMEKIT}, data=zeroconf.ZeroconfServiceInfo( - host="0.0.0.0", - addresses=["0.0.0.0"], + ip_address=ip_address("192.168.1.5"), + ip_addresses=[ip_address("192.168.1.5")], hostname="mock_hostname", name="mock_name", port=None, diff --git a/tests/components/netgear/test_config_flow.py b/tests/components/netgear/test_config_flow.py index 248ad3a69ea7b8..37787024fb6d06 100644 --- a/tests/components/netgear/test_config_flow.py +++ b/tests/components/netgear/test_config_flow.py @@ -76,41 +76,6 @@ def mock_controller_service(): yield service_mock -@pytest.fixture(name="service_5555") -def mock_controller_service_5555(): - """Mock a successful service.""" - with patch( - "homeassistant.components.netgear.async_setup_entry", return_value=True - ), patch("homeassistant.components.netgear.router.Netgear") as service_mock: - service_mock.return_value.get_info = Mock(return_value=ROUTER_INFOS) - service_mock.return_value.port = 5555 - service_mock.return_value.ssl = True - yield service_mock - - -@pytest.fixture(name="service_incomplete") -def mock_controller_service_incomplete(): - """Mock a successful service.""" - router_infos = ROUTER_INFOS.copy() - router_infos.pop("DeviceName") - with patch( - "homeassistant.components.netgear.async_setup_entry", return_value=True - ), patch("homeassistant.components.netgear.router.Netgear") as service_mock: - service_mock.return_value.get_info = Mock(return_value=router_infos) - service_mock.return_value.port = 80 - service_mock.return_value.ssl = False - yield service_mock - - -@pytest.fixture(name="service_failed") -def mock_controller_service_failed(): - """Mock a failed service.""" - with patch("homeassistant.components.netgear.router.Netgear") as service_mock: - service_mock.return_value.login_try_port = Mock(return_value=None) - service_mock.return_value.get_info = Mock(return_value=None) - yield service_mock - - async def test_user(hass: HomeAssistant, service) -> None: """Test user step.""" result = await hass.config_entries.flow.async_init( @@ -138,7 +103,7 @@ async def test_user(hass: HomeAssistant, service) -> None: assert result["data"][CONF_PASSWORD] == PASSWORD -async def test_user_connect_error(hass: HomeAssistant, service_failed) -> None: +async def test_user_connect_error(hass: HomeAssistant, service) -> None: """Test user step with connection failure.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} @@ -146,7 +111,23 @@ async def test_user_connect_error(hass: HomeAssistant, service_failed) -> None: assert result["type"] == data_entry_flow.FlowResultType.FORM assert result["step_id"] == "user" + service.return_value.get_info = Mock(return_value=None) + # Have to provide all config + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: HOST, + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + }, + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "info"} + + service.return_value.login_try_port = Mock(return_value=None) + result = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -160,7 +141,7 @@ async def test_user_connect_error(hass: HomeAssistant, service_failed) -> None: assert result["errors"] == {"base": "config"} -async def test_user_incomplete_info(hass: HomeAssistant, service_incomplete) -> None: +async def test_user_incomplete_info(hass: HomeAssistant, service) -> None: """Test user step with incomplete device info.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} @@ -168,6 +149,10 @@ async def test_user_incomplete_info(hass: HomeAssistant, service_incomplete) -> assert result["type"] == data_entry_flow.FlowResultType.FORM assert result["step_id"] == "user" + router_infos = ROUTER_INFOS.copy() + router_infos.pop("DeviceName") + service.return_value.get_info = Mock(return_value=router_infos) + # Have to provide all config result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -313,7 +298,7 @@ async def test_ssdp(hass: HomeAssistant, service) -> None: assert result["data"][CONF_PASSWORD] == PASSWORD -async def test_ssdp_port_5555(hass: HomeAssistant, service_5555) -> None: +async def test_ssdp_port_5555(hass: HomeAssistant, service) -> None: """Test ssdp step with port 5555.""" result = await hass.config_entries.flow.async_init( DOMAIN, @@ -332,6 +317,9 @@ async def test_ssdp_port_5555(hass: HomeAssistant, service_5555) -> None: assert result["type"] == data_entry_flow.FlowResultType.FORM assert result["step_id"] == "user" + service.return_value.port = 5555 + service.return_value.ssl = True + result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_PASSWORD: PASSWORD} ) diff --git a/tests/components/nexia/snapshots/test_diagnostics.ambr b/tests/components/nexia/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000000..f7a7df8854b7f7 --- /dev/null +++ b/tests/components/nexia/snapshots/test_diagnostics.ambr @@ -0,0 +1,10794 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'automations': list([ + dict({ + '_links': dict({ + 'edit': dict({ + 'href': 'https://www.mynexia.com/mobile/automation_edit_buffers?automation_id=3467876', + 'method': 'POST', + }), + 'filter_events': dict({ + 'href': 'https://www.mynexia.com/mobile/houses/123456/events/collection?sys_guid=472ae0d2-5d7c-4a1c-9e47-4d9035fdace5', + }), + 'nexia:history': dict({ + 'href': 'https://www.mynexia.com/mobile/houses/123456/events?automation_id=3467876', + }), + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/automations/3467876', + }), + }), + 'description': "When IFTTT activates the automation Upstairs West Wing will permanently hold the heat to 62.0 and cool to 83.0 AND Downstairs East Wing will permanently hold the heat to 62.0 and cool to 83.0 AND Downstairs West Wing will permanently hold the heat to 62.0 and cool to 83.0 AND Activate the mode named 'Away 12' AND Master Suite will permanently hold the heat to 62.0 and cool to 83.0", + 'enabled': True, + 'icon': list([ + dict({ + 'modifiers': list([ + ]), + 'name': 'gears', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'climate', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'climate', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'climate', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'plane', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'climate', + }), + ]), + 'id': 3467876, + 'name': 'Away for 12 Hours', + 'settings': list([ + ]), + 'triggers': list([ + ]), + }), + dict({ + '_links': dict({ + 'edit': dict({ + 'href': 'https://www.mynexia.com/mobile/automation_edit_buffers?automation_id=3467870', + 'method': 'POST', + }), + 'filter_events': dict({ + 'href': 'https://www.mynexia.com/mobile/houses/123456/events/collection?sys_guid=f63ee20c-3146-49a1-87c5-47429a063d15', + }), + 'nexia:history': dict({ + 'href': 'https://www.mynexia.com/mobile/houses/123456/events?automation_id=3467870', + }), + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/automations/3467870', + }), + }), + 'description': "When IFTTT activates the automation Upstairs West Wing will permanently hold the heat to 60.0 and cool to 85.0 AND Downstairs East Wing will permanently hold the heat to 60.0 and cool to 85.0 AND Downstairs West Wing will permanently hold the heat to 60.0 and cool to 85.0 AND Activate the mode named 'Away 24' AND Master Suite will permanently hold the heat to 60.0 and cool to 85.0", + 'enabled': True, + 'icon': list([ + dict({ + 'modifiers': list([ + ]), + 'name': 'gears', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'climate', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'climate', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'climate', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'plane', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'climate', + }), + ]), + 'id': 3467870, + 'name': 'Away For 24 Hours', + 'settings': list([ + ]), + 'triggers': list([ + ]), + }), + dict({ + '_links': dict({ + 'edit': dict({ + 'href': 'https://www.mynexia.com/mobile/automation_edit_buffers?automation_id=3452469', + 'method': 'POST', + }), + 'filter_events': dict({ + 'href': 'https://www.mynexia.com/mobile/houses/123456/events/collection?sys_guid=e5c59b93-efca-4937-9499-3f4c896ab17c', + }), + 'nexia:history': dict({ + 'href': 'https://www.mynexia.com/mobile/houses/123456/events?automation_id=3452469', + }), + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/automations/3452469', + }), + }), + 'description': "When IFTTT activates the automation Upstairs West Wing will permanently hold the heat to 63.0 and cool to 80.0 AND Downstairs East Wing will permanently hold the heat to 63.0 and cool to 79.0 AND Downstairs West Wing will permanently hold the heat to 63.0 and cool to 79.0 AND Upstairs West Wing will permanently hold the heat to 63.0 and cool to 81.0 AND Upstairs West Wing will change Fan Mode to Auto AND Downstairs East Wing will change Fan Mode to Auto AND Downstairs West Wing will change Fan Mode to Auto AND Activate the mode named 'Away Short' AND Master Suite will permanently hold the heat to 63.0 and cool to 79.0 AND Master Suite will change Fan Mode to Auto", + 'enabled': False, + 'icon': list([ + dict({ + 'modifiers': list([ + ]), + 'name': 'gears', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'climate', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'climate', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'climate', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'climate', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'settings', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'settings', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'settings', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'key', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'climate', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'settings', + }), + ]), + 'id': 3452469, + 'name': 'Away Short', + 'settings': list([ + ]), + 'triggers': list([ + ]), + }), + dict({ + '_links': dict({ + 'edit': dict({ + 'href': 'https://www.mynexia.com/mobile/automation_edit_buffers?automation_id=3452472', + 'method': 'POST', + }), + 'filter_events': dict({ + 'href': 'https://www.mynexia.com/mobile/houses/123456/events/collection?sys_guid=861b9fec-d259-4492-a798-5712251666c4', + }), + 'nexia:history': dict({ + 'href': 'https://www.mynexia.com/mobile/houses/123456/events?automation_id=3452472', + }), + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/automations/3452472', + }), + }), + 'description': "When IFTTT activates the automation Upstairs West Wing will Run Schedule AND Downstairs East Wing will Run Schedule AND Downstairs West Wing will Run Schedule AND Activate the mode named 'Home' AND Master Suite will Run Schedule", + 'enabled': True, + 'icon': list([ + dict({ + 'modifiers': list([ + ]), + 'name': 'gears', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'settings', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'settings', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'settings', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'at_home', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'settings', + }), + ]), + 'id': 3452472, + 'name': 'Home', + 'settings': list([ + ]), + 'triggers': list([ + ]), + }), + dict({ + '_links': dict({ + 'edit': dict({ + 'href': 'https://www.mynexia.com/mobile/automation_edit_buffers?automation_id=3454776', + 'method': 'POST', + }), + 'filter_events': dict({ + 'href': 'https://www.mynexia.com/mobile/houses/123456/events/collection?sys_guid=96c71d37-66aa-4cbb-84ff-a90412fd366a', + }), + 'nexia:history': dict({ + 'href': 'https://www.mynexia.com/mobile/houses/123456/events?automation_id=3454776', + }), + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/automations/3454776', + }), + }), + 'description': 'When IFTTT activates the automation Upstairs West Wing will permanently hold the heat to 60.0 and cool to 85.0 AND Downstairs East Wing will permanently hold the heat to 60.0 and cool to 85.0 AND Downstairs West Wing will permanently hold the heat to 60.0 and cool to 85.0 AND Upstairs West Wing will change Fan Mode to Auto AND Downstairs East Wing will change Fan Mode to Auto AND Downstairs West Wing will change Fan Mode to Auto AND Master Suite will permanently hold the heat to 60.0 and cool to 85.0 AND Master Suite will change Fan Mode to Auto', + 'enabled': True, + 'icon': list([ + dict({ + 'modifiers': list([ + ]), + 'name': 'gears', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'climate', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'climate', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'climate', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'settings', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'settings', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'settings', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'climate', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'settings', + }), + ]), + 'id': 3454776, + 'name': 'IFTTT Power Spike', + 'settings': list([ + ]), + 'triggers': list([ + ]), + }), + dict({ + '_links': dict({ + 'edit': dict({ + 'href': 'https://www.mynexia.com/mobile/automation_edit_buffers?automation_id=3454774', + 'method': 'POST', + }), + 'filter_events': dict({ + 'href': 'https://www.mynexia.com/mobile/houses/123456/events/collection?sys_guid=880c5287-d92c-4368-8494-e10975e92733', + }), + 'nexia:history': dict({ + 'href': 'https://www.mynexia.com/mobile/houses/123456/events?automation_id=3454774', + }), + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/automations/3454774', + }), + }), + 'description': 'When IFTTT activates the automation Upstairs West Wing will Run Schedule AND Downstairs East Wing will Run Schedule AND Downstairs West Wing will Run Schedule AND Master Suite will Run Schedule', + 'enabled': False, + 'icon': list([ + dict({ + 'modifiers': list([ + ]), + 'name': 'gears', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'settings', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'settings', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'settings', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'settings', + }), + ]), + 'id': 3454774, + 'name': 'IFTTT return to schedule', + 'settings': list([ + ]), + 'triggers': list([ + ]), + }), + dict({ + '_links': dict({ + 'edit': dict({ + 'href': 'https://www.mynexia.com/mobile/automation_edit_buffers?automation_id=3486078', + 'method': 'POST', + }), + 'filter_events': dict({ + 'href': 'https://www.mynexia.com/mobile/houses/123456/events/collection?sys_guid=d33c013b-2357-47a9-8c66-d2c3693173b0', + }), + 'nexia:history': dict({ + 'href': 'https://www.mynexia.com/mobile/houses/123456/events?automation_id=3486078', + }), + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/automations/3486078', + }), + }), + 'description': "When IFTTT activates the automation Upstairs West Wing will permanently hold the heat to 55.0 and cool to 90.0 AND Downstairs East Wing will permanently hold the heat to 55.0 and cool to 90.0 AND Downstairs West Wing will permanently hold the heat to 55.0 and cool to 90.0 AND Activate the mode named 'Power Outage'", + 'enabled': True, + 'icon': list([ + dict({ + 'modifiers': list([ + ]), + 'name': 'gears', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'climate', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'climate', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'climate', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'bell', + }), + ]), + 'id': 3486078, + 'name': 'Power Outage', + 'settings': list([ + ]), + 'triggers': list([ + ]), + }), + dict({ + '_links': dict({ + 'edit': dict({ + 'href': 'https://www.mynexia.com/mobile/automation_edit_buffers?automation_id=3486091', + 'method': 'POST', + }), + 'filter_events': dict({ + 'href': 'https://www.mynexia.com/mobile/houses/123456/events/collection?sys_guid=b9141df8-2e5e-4524-b8ef-efcbf48d775a', + }), + 'nexia:history': dict({ + 'href': 'https://www.mynexia.com/mobile/houses/123456/events?automation_id=3486091', + }), + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/automations/3486091', + }), + }), + 'description': "When IFTTT activates the automation Upstairs West Wing will Run Schedule AND Downstairs East Wing will Run Schedule AND Downstairs West Wing will Run Schedule AND Activate the mode named 'Home'", + 'enabled': True, + 'icon': list([ + dict({ + 'modifiers': list([ + ]), + 'name': 'gears', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'settings', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'settings', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'settings', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'at_home', + }), + ]), + 'id': 3486091, + 'name': 'Power Restored', + 'settings': list([ + ]), + 'triggers': list([ + ]), + }), + ]), + 'devices': list([ + dict({ + '_links': dict({ + 'filter_events': dict({ + 'href': 'https://www.mynexia.com/mobile/houses/123456/events/collection?sys_guid=cd9a70e8-fd0d-4b58-b071-05a202fd8953', + }), + 'nexia:history': dict({ + 'href': 'https://www.mynexia.com/mobile/houses/123456/events?device_id=2059661', + }), + 'pending_request': dict({ + 'polling_path': 'https://www.mynexia.com/backstage/announcements/be6d8ede5cac02fe8be18c334b04d539c9200fa9230eef63', + }), + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2059661', + }), + }), + 'connected': True, + 'delta': 3, + 'features': list([ + dict({ + 'items': list([ + dict({ + 'label': 'Model', + 'type': 'label_value', + 'value': 'XL1050', + }), + dict({ + 'label': 'AUID', + 'type': 'label_value', + 'value': '000000', + }), + dict({ + 'label': 'Firmware Build Number', + 'type': 'label_value', + 'value': '1581321824', + }), + dict({ + 'label': 'Firmware Build Date', + 'type': 'label_value', + 'value': '2020-02-10 08:03:44 UTC', + }), + dict({ + 'label': 'Firmware Version', + 'type': 'label_value', + 'value': '5.9.1', + }), + dict({ + 'label': 'Zoning Enabled', + 'type': 'label_value', + 'value': 'yes', + }), + ]), + 'name': 'advanced_info', + }), + dict({ + 'actions': dict({ + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': 'System Idle', + 'status_icon': None, + 'temperature': 71, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'members': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261002', + }), + }), + 'cooling_setpoint': 79, + 'current_zone_mode': 'AUTO', + 'features': list([ + dict({ + 'actions': dict({ + 'set_cool_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261002/setpoints', + }), + 'set_heat_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261002/setpoints', + }), + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool': 79, + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat': 63, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': '', + 'status_icon': None, + 'system_status': 'System Idle', + 'temperature': 71, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261002/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'AUTO', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261002/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261002/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83261002', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83261002', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83261002', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83261002&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-71', + ]), + 'name': 'thermostat', + }), + 'id': 83261002, + 'name': 'Living East', + 'operating_state': '', + 'setpoints': dict({ + 'cool': 79, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261002/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261002/zone_mode', + }), + }), + 'current_value': 'AUTO', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261002/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261002/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 71, + 'type': 'xxl_zone', + 'zone_status': '', + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261005', + }), + }), + 'cooling_setpoint': 79, + 'current_zone_mode': 'AUTO', + 'features': list([ + dict({ + 'actions': dict({ + 'set_cool_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261005/setpoints', + }), + 'set_heat_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261005/setpoints', + }), + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool': 79, + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat': 63, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': '', + 'status_icon': None, + 'system_status': 'System Idle', + 'temperature': 77, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261005/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'AUTO', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261005/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261005/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83261005', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83261005', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83261005', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83261005&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-77', + ]), + 'name': 'thermostat', + }), + 'id': 83261005, + 'name': 'Kitchen', + 'operating_state': '', + 'setpoints': dict({ + 'cool': 79, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261005/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261005/zone_mode', + }), + }), + 'current_value': 'AUTO', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261005/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261005/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 77, + 'type': 'xxl_zone', + 'zone_status': '', + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261008', + }), + }), + 'cooling_setpoint': 79, + 'current_zone_mode': 'AUTO', + 'features': list([ + dict({ + 'actions': dict({ + 'set_cool_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261008/setpoints', + }), + 'set_heat_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261008/setpoints', + }), + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool': 79, + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat': 63, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': '', + 'status_icon': None, + 'system_status': 'System Idle', + 'temperature': 72, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261008/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'AUTO', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261008/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261008/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83261008', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83261008', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83261008', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83261008&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-72', + ]), + 'name': 'thermostat', + }), + 'id': 83261008, + 'name': 'Down Bedroom', + 'operating_state': '', + 'setpoints': dict({ + 'cool': 79, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261008/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261008/zone_mode', + }), + }), + 'current_value': 'AUTO', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261008/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261008/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 72, + 'type': 'xxl_zone', + 'zone_status': '', + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261011', + }), + }), + 'cooling_setpoint': 79, + 'current_zone_mode': 'AUTO', + 'features': list([ + dict({ + 'actions': dict({ + 'set_cool_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261011/setpoints', + }), + 'set_heat_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261011/setpoints', + }), + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool': 79, + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat': 63, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': '', + 'status_icon': None, + 'system_status': 'System Idle', + 'temperature': 78, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261011/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'AUTO', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261011/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261011/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83261011', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83261011', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83261011', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83261011&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-78', + ]), + 'name': 'thermostat', + }), + 'id': 83261011, + 'name': 'Tech Room', + 'operating_state': '', + 'setpoints': dict({ + 'cool': 79, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261011/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261011/zone_mode', + }), + }), + 'current_value': 'AUTO', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261011/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261011/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 78, + 'type': 'xxl_zone', + 'zone_status': '', + }), + ]), + 'name': 'group', + }), + dict({ + 'actions': dict({ + 'update_thermostat_fan_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2059661/fan_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Fan Mode', + 'name': 'thermostat_fan_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_fan_mode', + 'label': 'Fan Mode', + 'value': 'thermostat_fan_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'auto', + }), + dict({ + 'label': 'On', + 'value': 'on', + }), + dict({ + 'label': 'Circulate', + 'value': 'circulate', + }), + ]), + 'status_icon': dict({ + 'modifiers': list([ + ]), + 'name': 'thermostat_fan_off', + }), + 'value': 'auto', + }), + dict({ + 'compressor_speed': 0.0, + 'name': 'thermostat_compressor_speed', + }), + dict({ + 'actions': dict({ + 'get_monthly_runtime_history': dict({ + 'href': 'https://www.mynexia.com/mobile/runtime_history/2059661?report_type=monthly', + 'method': 'GET', + }), + 'get_runtime_history': dict({ + 'href': 'https://www.mynexia.com/mobile/runtime_history/2059661?report_type=daily', + 'method': 'GET', + }), + }), + 'name': 'runtime_history', + }), + ]), + 'has_indoor_humidity': True, + 'has_outdoor_temperature': True, + 'icon': list([ + dict({ + 'modifiers': list([ + 'temperature-71', + ]), + 'name': 'thermostat', + }), + dict({ + 'modifiers': list([ + 'temperature-77', + ]), + 'name': 'thermostat', + }), + dict({ + 'modifiers': list([ + 'temperature-72', + ]), + 'name': 'thermostat', + }), + dict({ + 'modifiers': list([ + 'temperature-78', + ]), + 'name': 'thermostat', + }), + ]), + 'id': 2059661, + 'indoor_humidity': '36', + 'last_updated_at': '2020-03-11T15:15:53.000-05:00', + 'name': 'Downstairs East Wing', + 'name_editable': True, + 'outdoor_temperature': '88', + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2059661/fan_mode', + }), + }), + 'current_value': 'auto', + 'labels': list([ + 'Auto', + 'On', + 'Circulate', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'auto', + }), + dict({ + 'label': 'On', + 'value': 'on', + }), + dict({ + 'label': 'Circulate', + 'value': 'circulate', + }), + ]), + 'title': 'Fan Mode', + 'type': 'fan_mode', + 'values': list([ + 'auto', + 'on', + 'circulate', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2059661/fan_speed', + }), + }), + 'current_value': 0.35, + 'labels': list([ + '35%', + '40%', + '45%', + '50%', + '55%', + '60%', + '65%', + '70%', + '75%', + '80%', + '85%', + '90%', + '95%', + '100%', + ]), + 'options': list([ + dict({ + 'label': '35%', + 'value': 0.35, + }), + dict({ + 'label': '40%', + 'value': 0.4, + }), + dict({ + 'label': '45%', + 'value': 0.45, + }), + dict({ + 'label': '50%', + 'value': 0.5, + }), + dict({ + 'label': '55%', + 'value': 0.55, + }), + dict({ + 'label': '60%', + 'value': 0.6, + }), + dict({ + 'label': '65%', + 'value': 0.65, + }), + dict({ + 'label': '70%', + 'value': 0.7, + }), + dict({ + 'label': '75%', + 'value': 0.75, + }), + dict({ + 'label': '80%', + 'value': 0.8, + }), + dict({ + 'label': '85%', + 'value': 0.85, + }), + dict({ + 'label': '90%', + 'value': 0.9, + }), + dict({ + 'label': '95%', + 'value': 0.95, + }), + dict({ + 'label': '100%', + 'value': 1.0, + }), + ]), + 'title': 'Fan Speed', + 'type': 'fan_speed', + 'values': list([ + 0.35, + 0.4, + 0.45, + 0.5, + 0.55, + 0.6, + 0.65, + 0.7, + 0.75, + 0.8, + 0.85, + 0.9, + 0.95, + 1.0, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2059661/fan_circulation_time', + }), + }), + 'current_value': 30, + 'labels': list([ + '10 minutes', + '15 minutes', + '20 minutes', + '25 minutes', + '30 minutes', + '35 minutes', + '40 minutes', + '45 minutes', + '50 minutes', + '55 minutes', + ]), + 'options': list([ + dict({ + 'label': '10 minutes', + 'value': 10, + }), + dict({ + 'label': '15 minutes', + 'value': 15, + }), + dict({ + 'label': '20 minutes', + 'value': 20, + }), + dict({ + 'label': '25 minutes', + 'value': 25, + }), + dict({ + 'label': '30 minutes', + 'value': 30, + }), + dict({ + 'label': '35 minutes', + 'value': 35, + }), + dict({ + 'label': '40 minutes', + 'value': 40, + }), + dict({ + 'label': '45 minutes', + 'value': 45, + }), + dict({ + 'label': '50 minutes', + 'value': 50, + }), + dict({ + 'label': '55 minutes', + 'value': 55, + }), + ]), + 'title': 'Fan Circulation Time', + 'type': 'fan_circulation_time', + 'values': list([ + 10, + 15, + 20, + 25, + 30, + 35, + 40, + 45, + 50, + 55, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2059661/air_cleaner_mode', + }), + }), + 'current_value': 'auto', + 'labels': list([ + 'Auto', + 'Quick', + 'Allergy', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'auto', + }), + dict({ + 'label': 'Quick', + 'value': 'quick', + }), + dict({ + 'label': 'Allergy', + 'value': 'allergy', + }), + ]), + 'title': 'Air Cleaner Mode', + 'type': 'air_cleaner_mode', + 'values': list([ + 'auto', + 'quick', + 'allergy', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2059661/dehumidify', + }), + }), + 'current_value': 0.5, + 'labels': list([ + '35%', + '40%', + '45%', + '50%', + '55%', + '60%', + '65%', + ]), + 'options': list([ + dict({ + 'label': '35%', + 'value': 0.35, + }), + dict({ + 'label': '40%', + 'value': 0.4, + }), + dict({ + 'label': '45%', + 'value': 0.45, + }), + dict({ + 'label': '50%', + 'value': 0.5, + }), + dict({ + 'label': '55%', + 'value': 0.55, + }), + dict({ + 'label': '60%', + 'value': 0.6, + }), + dict({ + 'label': '65%', + 'value': 0.65, + }), + ]), + 'title': 'Cooling Dehumidify Set Point', + 'type': 'dehumidify', + 'values': list([ + 0.35, + 0.4, + 0.45, + 0.5, + 0.55, + 0.6, + 0.65, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2059661/scale', + }), + }), + 'current_value': 'f', + 'labels': list([ + 'F', + 'C', + ]), + 'options': list([ + dict({ + 'label': 'F', + 'value': 'f', + }), + dict({ + 'label': 'C', + 'value': 'c', + }), + ]), + 'title': 'Temperature Scale', + 'type': 'scale', + 'values': list([ + 'f', + 'c', + ]), + }), + ]), + 'status_secondary': None, + 'status_tertiary': None, + 'system_status': 'System Idle', + 'type': 'xxl_thermostat', + 'zones': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261002', + }), + }), + 'cooling_setpoint': 79, + 'current_zone_mode': 'AUTO', + 'features': list([ + dict({ + 'actions': dict({ + 'set_cool_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261002/setpoints', + }), + 'set_heat_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261002/setpoints', + }), + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool': 79, + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat': 63, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': '', + 'status_icon': None, + 'system_status': 'System Idle', + 'temperature': 71, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261002/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'AUTO', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261002/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261002/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83261002', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83261002', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83261002', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83261002&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-71', + ]), + 'name': 'thermostat', + }), + 'id': 83261002, + 'name': 'Living East', + 'operating_state': '', + 'setpoints': dict({ + 'cool': 79, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261002/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261002/zone_mode', + }), + }), + 'current_value': 'AUTO', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261002/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261002/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 71, + 'type': 'xxl_zone', + 'zone_status': '', + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261005', + }), + }), + 'cooling_setpoint': 79, + 'current_zone_mode': 'AUTO', + 'features': list([ + dict({ + 'actions': dict({ + 'set_cool_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261005/setpoints', + }), + 'set_heat_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261005/setpoints', + }), + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool': 79, + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat': 63, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': '', + 'status_icon': None, + 'system_status': 'System Idle', + 'temperature': 77, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261005/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'AUTO', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261005/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261005/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83261005', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83261005', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83261005', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83261005&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-77', + ]), + 'name': 'thermostat', + }), + 'id': 83261005, + 'name': 'Kitchen', + 'operating_state': '', + 'setpoints': dict({ + 'cool': 79, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261005/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261005/zone_mode', + }), + }), + 'current_value': 'AUTO', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261005/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261005/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 77, + 'type': 'xxl_zone', + 'zone_status': '', + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261008', + }), + }), + 'cooling_setpoint': 79, + 'current_zone_mode': 'AUTO', + 'features': list([ + dict({ + 'actions': dict({ + 'set_cool_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261008/setpoints', + }), + 'set_heat_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261008/setpoints', + }), + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool': 79, + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat': 63, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': '', + 'status_icon': None, + 'system_status': 'System Idle', + 'temperature': 72, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261008/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'AUTO', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261008/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261008/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83261008', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83261008', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83261008', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83261008&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-72', + ]), + 'name': 'thermostat', + }), + 'id': 83261008, + 'name': 'Down Bedroom', + 'operating_state': '', + 'setpoints': dict({ + 'cool': 79, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261008/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261008/zone_mode', + }), + }), + 'current_value': 'AUTO', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261008/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261008/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 72, + 'type': 'xxl_zone', + 'zone_status': '', + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261011', + }), + }), + 'cooling_setpoint': 79, + 'current_zone_mode': 'AUTO', + 'features': list([ + dict({ + 'actions': dict({ + 'set_cool_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261011/setpoints', + }), + 'set_heat_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261011/setpoints', + }), + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool': 79, + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat': 63, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': '', + 'status_icon': None, + 'system_status': 'System Idle', + 'temperature': 78, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261011/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'AUTO', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261011/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261011/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83261011', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83261011', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83261011', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83261011&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-78', + ]), + 'name': 'thermostat', + }), + 'id': 83261011, + 'name': 'Tech Room', + 'operating_state': '', + 'setpoints': dict({ + 'cool': 79, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261011/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261011/zone_mode', + }), + }), + 'current_value': 'AUTO', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261011/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261011/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 78, + 'type': 'xxl_zone', + 'zone_status': '', + }), + ]), + }), + dict({ + '_links': dict({ + 'filter_events': dict({ + 'href': 'https://www.mynexia.com/mobile/houses/123456/events/collection?sys_guid=5aae72a6-1bd0-4d84-9bfd-673e7bc4907c', + }), + 'nexia:history': dict({ + 'href': 'https://www.mynexia.com/mobile/houses/123456/events?device_id=2059676', + }), + 'pending_request': dict({ + 'polling_path': 'https://www.mynexia.com/backstage/announcements/3412f1d96eb0c5edb5466c3c0598af60c06f8443f21e9bcb', + }), + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2059676', + }), + }), + 'connected': True, + 'delta': 3, + 'features': list([ + dict({ + 'items': list([ + dict({ + 'label': 'Model', + 'type': 'label_value', + 'value': 'XL1050', + }), + dict({ + 'label': 'AUID', + 'type': 'label_value', + 'value': '02853E08', + }), + dict({ + 'label': 'Firmware Build Number', + 'type': 'label_value', + 'value': '1581321824', + }), + dict({ + 'label': 'Firmware Build Date', + 'type': 'label_value', + 'value': '2020-02-10 08:03:44 UTC', + }), + dict({ + 'label': 'Firmware Version', + 'type': 'label_value', + 'value': '5.9.1', + }), + dict({ + 'label': 'Zoning Enabled', + 'type': 'label_value', + 'value': 'yes', + }), + ]), + 'name': 'advanced_info', + }), + dict({ + 'actions': dict({ + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': 'System Idle', + 'status_icon': None, + 'temperature': 75, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'members': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261015', + }), + }), + 'cooling_setpoint': 79, + 'current_zone_mode': 'AUTO', + 'features': list([ + dict({ + 'actions': dict({ + 'set_cool_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261015/setpoints', + }), + 'set_heat_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261015/setpoints', + }), + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool': 79, + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat': 63, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': '', + 'status_icon': None, + 'system_status': 'System Idle', + 'temperature': 75, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261015/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'AUTO', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261015/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261015/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83261015', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83261015', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83261015', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83261015&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-75', + ]), + 'name': 'thermostat', + }), + 'id': 83261015, + 'name': 'Living West', + 'operating_state': '', + 'setpoints': dict({ + 'cool': 79, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261015/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261015/zone_mode', + }), + }), + 'current_value': 'AUTO', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261015/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261015/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 75, + 'type': 'xxl_zone', + 'zone_status': '', + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261018', + }), + }), + 'cooling_setpoint': 79, + 'current_zone_mode': 'AUTO', + 'features': list([ + dict({ + 'actions': dict({ + 'set_cool_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261018/setpoints', + }), + 'set_heat_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261018/setpoints', + }), + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool': 79, + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat': 63, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': '', + 'status_icon': None, + 'system_status': 'System Idle', + 'temperature': 75, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261018/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'AUTO', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261018/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261018/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83261018', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83261018', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83261018', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83261018&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-75', + ]), + 'name': 'thermostat', + }), + 'id': 83261018, + 'name': 'David Office', + 'operating_state': '', + 'setpoints': dict({ + 'cool': 79, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261018/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261018/zone_mode', + }), + }), + 'current_value': 'AUTO', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261018/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261018/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 75, + 'type': 'xxl_zone', + 'zone_status': '', + }), + ]), + 'name': 'group', + }), + dict({ + 'actions': dict({ + 'update_thermostat_fan_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2059676/fan_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Fan Mode', + 'name': 'thermostat_fan_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_fan_mode', + 'label': 'Fan Mode', + 'value': 'thermostat_fan_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'auto', + }), + dict({ + 'label': 'On', + 'value': 'on', + }), + dict({ + 'label': 'Circulate', + 'value': 'circulate', + }), + ]), + 'status_icon': dict({ + 'modifiers': list([ + ]), + 'name': 'thermostat_fan_off', + }), + 'value': 'auto', + }), + dict({ + 'compressor_speed': 0.0, + 'name': 'thermostat_compressor_speed', + }), + dict({ + 'actions': dict({ + 'get_monthly_runtime_history': dict({ + 'href': 'https://www.mynexia.com/mobile/runtime_history/2059676?report_type=monthly', + 'method': 'GET', + }), + 'get_runtime_history': dict({ + 'href': 'https://www.mynexia.com/mobile/runtime_history/2059676?report_type=daily', + 'method': 'GET', + }), + }), + 'name': 'runtime_history', + }), + ]), + 'has_indoor_humidity': True, + 'has_outdoor_temperature': True, + 'icon': list([ + dict({ + 'modifiers': list([ + 'temperature-75', + ]), + 'name': 'thermostat', + }), + dict({ + 'modifiers': list([ + 'temperature-75', + ]), + 'name': 'thermostat', + }), + ]), + 'id': 2059676, + 'indoor_humidity': '52', + 'last_updated_at': '2020-03-11T15:15:53.000-05:00', + 'name': 'Downstairs West Wing', + 'name_editable': True, + 'outdoor_temperature': '88', + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2059676/fan_mode', + }), + }), + 'current_value': 'auto', + 'labels': list([ + 'Auto', + 'On', + 'Circulate', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'auto', + }), + dict({ + 'label': 'On', + 'value': 'on', + }), + dict({ + 'label': 'Circulate', + 'value': 'circulate', + }), + ]), + 'title': 'Fan Mode', + 'type': 'fan_mode', + 'values': list([ + 'auto', + 'on', + 'circulate', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2059676/fan_speed', + }), + }), + 'current_value': 0.35, + 'labels': list([ + '35%', + '40%', + '45%', + '50%', + '55%', + '60%', + '65%', + '70%', + '75%', + '80%', + '85%', + '90%', + '95%', + '100%', + ]), + 'options': list([ + dict({ + 'label': '35%', + 'value': 0.35, + }), + dict({ + 'label': '40%', + 'value': 0.4, + }), + dict({ + 'label': '45%', + 'value': 0.45, + }), + dict({ + 'label': '50%', + 'value': 0.5, + }), + dict({ + 'label': '55%', + 'value': 0.55, + }), + dict({ + 'label': '60%', + 'value': 0.6, + }), + dict({ + 'label': '65%', + 'value': 0.65, + }), + dict({ + 'label': '70%', + 'value': 0.7, + }), + dict({ + 'label': '75%', + 'value': 0.75, + }), + dict({ + 'label': '80%', + 'value': 0.8, + }), + dict({ + 'label': '85%', + 'value': 0.85, + }), + dict({ + 'label': '90%', + 'value': 0.9, + }), + dict({ + 'label': '95%', + 'value': 0.95, + }), + dict({ + 'label': '100%', + 'value': 1.0, + }), + ]), + 'title': 'Fan Speed', + 'type': 'fan_speed', + 'values': list([ + 0.35, + 0.4, + 0.45, + 0.5, + 0.55, + 0.6, + 0.65, + 0.7, + 0.75, + 0.8, + 0.85, + 0.9, + 0.95, + 1.0, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2059676/fan_circulation_time', + }), + }), + 'current_value': 30, + 'labels': list([ + '10 minutes', + '15 minutes', + '20 minutes', + '25 minutes', + '30 minutes', + '35 minutes', + '40 minutes', + '45 minutes', + '50 minutes', + '55 minutes', + ]), + 'options': list([ + dict({ + 'label': '10 minutes', + 'value': 10, + }), + dict({ + 'label': '15 minutes', + 'value': 15, + }), + dict({ + 'label': '20 minutes', + 'value': 20, + }), + dict({ + 'label': '25 minutes', + 'value': 25, + }), + dict({ + 'label': '30 minutes', + 'value': 30, + }), + dict({ + 'label': '35 minutes', + 'value': 35, + }), + dict({ + 'label': '40 minutes', + 'value': 40, + }), + dict({ + 'label': '45 minutes', + 'value': 45, + }), + dict({ + 'label': '50 minutes', + 'value': 50, + }), + dict({ + 'label': '55 minutes', + 'value': 55, + }), + ]), + 'title': 'Fan Circulation Time', + 'type': 'fan_circulation_time', + 'values': list([ + 10, + 15, + 20, + 25, + 30, + 35, + 40, + 45, + 50, + 55, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2059676/air_cleaner_mode', + }), + }), + 'current_value': 'auto', + 'labels': list([ + 'Auto', + 'Quick', + 'Allergy', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'auto', + }), + dict({ + 'label': 'Quick', + 'value': 'quick', + }), + dict({ + 'label': 'Allergy', + 'value': 'allergy', + }), + ]), + 'title': 'Air Cleaner Mode', + 'type': 'air_cleaner_mode', + 'values': list([ + 'auto', + 'quick', + 'allergy', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2059676/dehumidify', + }), + }), + 'current_value': 0.45, + 'labels': list([ + '35%', + '40%', + '45%', + '50%', + '55%', + '60%', + '65%', + ]), + 'options': list([ + dict({ + 'label': '35%', + 'value': 0.35, + }), + dict({ + 'label': '40%', + 'value': 0.4, + }), + dict({ + 'label': '45%', + 'value': 0.45, + }), + dict({ + 'label': '50%', + 'value': 0.5, + }), + dict({ + 'label': '55%', + 'value': 0.55, + }), + dict({ + 'label': '60%', + 'value': 0.6, + }), + dict({ + 'label': '65%', + 'value': 0.65, + }), + ]), + 'title': 'Cooling Dehumidify Set Point', + 'type': 'dehumidify', + 'values': list([ + 0.35, + 0.4, + 0.45, + 0.5, + 0.55, + 0.6, + 0.65, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2059676/scale', + }), + }), + 'current_value': 'f', + 'labels': list([ + 'F', + 'C', + ]), + 'options': list([ + dict({ + 'label': 'F', + 'value': 'f', + }), + dict({ + 'label': 'C', + 'value': 'c', + }), + ]), + 'title': 'Temperature Scale', + 'type': 'scale', + 'values': list([ + 'f', + 'c', + ]), + }), + ]), + 'status_secondary': None, + 'status_tertiary': None, + 'system_status': 'System Idle', + 'type': 'xxl_thermostat', + 'zones': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261015', + }), + }), + 'cooling_setpoint': 79, + 'current_zone_mode': 'AUTO', + 'features': list([ + dict({ + 'actions': dict({ + 'set_cool_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261015/setpoints', + }), + 'set_heat_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261015/setpoints', + }), + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool': 79, + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat': 63, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': '', + 'status_icon': None, + 'system_status': 'System Idle', + 'temperature': 75, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261015/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'AUTO', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261015/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261015/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83261015', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83261015', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83261015', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83261015&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-75', + ]), + 'name': 'thermostat', + }), + 'id': 83261015, + 'name': 'Living West', + 'operating_state': '', + 'setpoints': dict({ + 'cool': 79, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261015/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261015/zone_mode', + }), + }), + 'current_value': 'AUTO', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261015/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261015/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 75, + 'type': 'xxl_zone', + 'zone_status': '', + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261018', + }), + }), + 'cooling_setpoint': 79, + 'current_zone_mode': 'AUTO', + 'features': list([ + dict({ + 'actions': dict({ + 'set_cool_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261018/setpoints', + }), + 'set_heat_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261018/setpoints', + }), + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool': 79, + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat': 63, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': '', + 'status_icon': None, + 'system_status': 'System Idle', + 'temperature': 75, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261018/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'AUTO', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261018/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261018/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83261018', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83261018', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83261018', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83261018&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-75', + ]), + 'name': 'thermostat', + }), + 'id': 83261018, + 'name': 'David Office', + 'operating_state': '', + 'setpoints': dict({ + 'cool': 79, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261018/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261018/zone_mode', + }), + }), + 'current_value': 'AUTO', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261018/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261018/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 75, + 'type': 'xxl_zone', + 'zone_status': '', + }), + ]), + }), + dict({ + '_links': dict({ + 'filter_events': dict({ + 'href': 'https://www.mynexia.com/mobile/houses/123456/events/collection?sys_guid=e3fc90c7-2885-4f57-ae76-99e9ec81eef0', + }), + 'nexia:history': dict({ + 'href': 'https://www.mynexia.com/mobile/houses/123456/events?device_id=2293892', + }), + 'pending_request': dict({ + 'polling_path': 'https://www.mynexia.com/backstage/announcements/967361e8aed874aa5230930fd0e0bbd8b653261e982a6e0e', + }), + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2293892', + }), + }), + 'connected': True, + 'delta': 3, + 'features': list([ + dict({ + 'items': list([ + dict({ + 'label': 'Model', + 'type': 'label_value', + 'value': 'XL1050', + }), + dict({ + 'label': 'AUID', + 'type': 'label_value', + 'value': '0281B02C', + }), + dict({ + 'label': 'Firmware Build Number', + 'type': 'label_value', + 'value': '1581321824', + }), + dict({ + 'label': 'Firmware Build Date', + 'type': 'label_value', + 'value': '2020-02-10 08:03:44 UTC', + }), + dict({ + 'label': 'Firmware Version', + 'type': 'label_value', + 'value': '5.9.1', + }), + dict({ + 'label': 'Zoning Enabled', + 'type': 'label_value', + 'value': 'yes', + }), + ]), + 'name': 'advanced_info', + }), + dict({ + 'actions': dict({ + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': 'Cooling', + 'status_icon': dict({ + 'modifiers': list([ + ]), + 'name': 'cooling', + }), + 'temperature': 73, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'members': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394133', + }), + }), + 'cooling_setpoint': 79, + 'current_zone_mode': 'AUTO', + 'features': list([ + dict({ + 'actions': dict({ + 'set_cool_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394133/setpoints', + }), + 'set_heat_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394133/setpoints', + }), + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool': 79, + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat': 63, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': 'Relieving Air', + 'status_icon': dict({ + 'modifiers': list([ + ]), + 'name': 'cooling', + }), + 'system_status': 'Cooling', + 'temperature': 73, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394133/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'AUTO', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394133/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394133/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83394133', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83394133', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83394133', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83394133&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-73', + ]), + 'name': 'thermostat', + }), + 'id': 83394133, + 'name': 'Bath Closet', + 'operating_state': 'Relieving Air', + 'setpoints': dict({ + 'cool': 79, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394133/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394133/zone_mode', + }), + }), + 'current_value': 'AUTO', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394133/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394133/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 73, + 'type': 'xxl_zone', + 'zone_status': 'Relieving Air', + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394130', + }), + }), + 'cooling_setpoint': 71, + 'current_zone_mode': 'AUTO', + 'features': list([ + dict({ + 'actions': dict({ + 'set_cool_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394130/setpoints', + }), + 'set_heat_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394130/setpoints', + }), + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool': 71, + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat': 63, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': 'Damper Open', + 'status_icon': dict({ + 'modifiers': list([ + ]), + 'name': 'cooling', + }), + 'system_status': 'Cooling', + 'temperature': 74, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394130/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'AUTO', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394130/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394130/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83394130', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83394130', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83394130', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83394130&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-74', + ]), + 'name': 'thermostat', + }), + 'id': 83394130, + 'name': 'Master', + 'operating_state': 'Damper Open', + 'setpoints': dict({ + 'cool': 71, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394130/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394130/zone_mode', + }), + }), + 'current_value': 'AUTO', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394130/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394130/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 74, + 'type': 'xxl_zone', + 'zone_status': 'Damper Open', + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394136', + }), + }), + 'cooling_setpoint': 79, + 'current_zone_mode': 'AUTO', + 'features': list([ + dict({ + 'actions': dict({ + 'set_cool_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394136/setpoints', + }), + 'set_heat_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394136/setpoints', + }), + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool': 79, + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat': 63, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': 'Relieving Air', + 'status_icon': dict({ + 'modifiers': list([ + ]), + 'name': 'cooling', + }), + 'system_status': 'Cooling', + 'temperature': 73, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394136/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'AUTO', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394136/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394136/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83394136', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83394136', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83394136', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83394136&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-73', + ]), + 'name': 'thermostat', + }), + 'id': 83394136, + 'name': 'Nick Office', + 'operating_state': 'Relieving Air', + 'setpoints': dict({ + 'cool': 79, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394136/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394136/zone_mode', + }), + }), + 'current_value': 'AUTO', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394136/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394136/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 73, + 'type': 'xxl_zone', + 'zone_status': 'Relieving Air', + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394127', + }), + }), + 'cooling_setpoint': 79, + 'current_zone_mode': 'AUTO', + 'features': list([ + dict({ + 'actions': dict({ + 'set_cool_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394127/setpoints', + }), + 'set_heat_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394127/setpoints', + }), + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool': 79, + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat': 63, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': 'Damper Closed', + 'status_icon': dict({ + 'modifiers': list([ + ]), + 'name': 'cooling', + }), + 'system_status': 'Cooling', + 'temperature': 72, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394127/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'AUTO', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394127/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394127/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83394127', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83394127', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83394127', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83394127&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-72', + ]), + 'name': 'thermostat', + }), + 'id': 83394127, + 'name': 'Snooze Room', + 'operating_state': 'Damper Closed', + 'setpoints': dict({ + 'cool': 79, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394127/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394127/zone_mode', + }), + }), + 'current_value': 'AUTO', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394127/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394127/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 72, + 'type': 'xxl_zone', + 'zone_status': 'Damper Closed', + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394139', + }), + }), + 'cooling_setpoint': 79, + 'current_zone_mode': 'AUTO', + 'features': list([ + dict({ + 'actions': dict({ + 'set_cool_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394139/setpoints', + }), + 'set_heat_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394139/setpoints', + }), + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool': 79, + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat': 63, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': 'Damper Closed', + 'status_icon': dict({ + 'modifiers': list([ + ]), + 'name': 'cooling', + }), + 'system_status': 'Cooling', + 'temperature': 74, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394139/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'AUTO', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394139/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394139/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83394139', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83394139', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83394139', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83394139&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-74', + ]), + 'name': 'thermostat', + }), + 'id': 83394139, + 'name': 'Safe Room', + 'operating_state': 'Damper Closed', + 'setpoints': dict({ + 'cool': 79, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394139/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394139/zone_mode', + }), + }), + 'current_value': 'AUTO', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394139/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394139/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 74, + 'type': 'xxl_zone', + 'zone_status': 'Damper Closed', + }), + ]), + 'name': 'group', + }), + dict({ + 'actions': dict({ + 'update_thermostat_fan_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2293892/fan_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Fan Mode', + 'name': 'thermostat_fan_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_fan_mode', + 'label': 'Fan Mode', + 'value': 'thermostat_fan_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'auto', + }), + dict({ + 'label': 'On', + 'value': 'on', + }), + dict({ + 'label': 'Circulate', + 'value': 'circulate', + }), + ]), + 'status_icon': dict({ + 'modifiers': list([ + ]), + 'name': 'thermostat_fan_on', + }), + 'value': 'auto', + }), + dict({ + 'compressor_speed': 0.69, + 'name': 'thermostat_compressor_speed', + }), + dict({ + 'actions': dict({ + 'get_monthly_runtime_history': dict({ + 'href': 'https://www.mynexia.com/mobile/runtime_history/2293892?report_type=monthly', + 'method': 'GET', + }), + 'get_runtime_history': dict({ + 'href': 'https://www.mynexia.com/mobile/runtime_history/2293892?report_type=daily', + 'method': 'GET', + }), + }), + 'name': 'runtime_history', + }), + ]), + 'has_indoor_humidity': True, + 'has_outdoor_temperature': True, + 'icon': list([ + dict({ + 'modifiers': list([ + 'temperature-73', + ]), + 'name': 'thermostat', + }), + dict({ + 'modifiers': list([ + 'temperature-74', + ]), + 'name': 'thermostat', + }), + dict({ + 'modifiers': list([ + 'temperature-73', + ]), + 'name': 'thermostat', + }), + dict({ + 'modifiers': list([ + 'temperature-72', + ]), + 'name': 'thermostat', + }), + dict({ + 'modifiers': list([ + 'temperature-74', + ]), + 'name': 'thermostat', + }), + ]), + 'id': 2293892, + 'indoor_humidity': '52', + 'last_updated_at': '2020-03-11T15:15:53.000-05:00', + 'name': 'Master Suite', + 'name_editable': True, + 'outdoor_temperature': '87', + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2293892/fan_mode', + }), + }), + 'current_value': 'auto', + 'labels': list([ + 'Auto', + 'On', + 'Circulate', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'auto', + }), + dict({ + 'label': 'On', + 'value': 'on', + }), + dict({ + 'label': 'Circulate', + 'value': 'circulate', + }), + ]), + 'title': 'Fan Mode', + 'type': 'fan_mode', + 'values': list([ + 'auto', + 'on', + 'circulate', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2293892/fan_speed', + }), + }), + 'current_value': 0.35, + 'labels': list([ + '35%', + '40%', + '45%', + '50%', + '55%', + '60%', + '65%', + '70%', + '75%', + '80%', + '85%', + '90%', + '95%', + '100%', + ]), + 'options': list([ + dict({ + 'label': '35%', + 'value': 0.35, + }), + dict({ + 'label': '40%', + 'value': 0.4, + }), + dict({ + 'label': '45%', + 'value': 0.45, + }), + dict({ + 'label': '50%', + 'value': 0.5, + }), + dict({ + 'label': '55%', + 'value': 0.55, + }), + dict({ + 'label': '60%', + 'value': 0.6, + }), + dict({ + 'label': '65%', + 'value': 0.65, + }), + dict({ + 'label': '70%', + 'value': 0.7, + }), + dict({ + 'label': '75%', + 'value': 0.75, + }), + dict({ + 'label': '80%', + 'value': 0.8, + }), + dict({ + 'label': '85%', + 'value': 0.85, + }), + dict({ + 'label': '90%', + 'value': 0.9, + }), + dict({ + 'label': '95%', + 'value': 0.95, + }), + dict({ + 'label': '100%', + 'value': 1.0, + }), + ]), + 'title': 'Fan Speed', + 'type': 'fan_speed', + 'values': list([ + 0.35, + 0.4, + 0.45, + 0.5, + 0.55, + 0.6, + 0.65, + 0.7, + 0.75, + 0.8, + 0.85, + 0.9, + 0.95, + 1.0, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2293892/fan_circulation_time', + }), + }), + 'current_value': 30, + 'labels': list([ + '10 minutes', + '15 minutes', + '20 minutes', + '25 minutes', + '30 minutes', + '35 minutes', + '40 minutes', + '45 minutes', + '50 minutes', + '55 minutes', + ]), + 'options': list([ + dict({ + 'label': '10 minutes', + 'value': 10, + }), + dict({ + 'label': '15 minutes', + 'value': 15, + }), + dict({ + 'label': '20 minutes', + 'value': 20, + }), + dict({ + 'label': '25 minutes', + 'value': 25, + }), + dict({ + 'label': '30 minutes', + 'value': 30, + }), + dict({ + 'label': '35 minutes', + 'value': 35, + }), + dict({ + 'label': '40 minutes', + 'value': 40, + }), + dict({ + 'label': '45 minutes', + 'value': 45, + }), + dict({ + 'label': '50 minutes', + 'value': 50, + }), + dict({ + 'label': '55 minutes', + 'value': 55, + }), + ]), + 'title': 'Fan Circulation Time', + 'type': 'fan_circulation_time', + 'values': list([ + 10, + 15, + 20, + 25, + 30, + 35, + 40, + 45, + 50, + 55, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2293892/air_cleaner_mode', + }), + }), + 'current_value': 'auto', + 'labels': list([ + 'Auto', + 'Quick', + 'Allergy', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'auto', + }), + dict({ + 'label': 'Quick', + 'value': 'quick', + }), + dict({ + 'label': 'Allergy', + 'value': 'allergy', + }), + ]), + 'title': 'Air Cleaner Mode', + 'type': 'air_cleaner_mode', + 'values': list([ + 'auto', + 'quick', + 'allergy', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2293892/dehumidify', + }), + }), + 'current_value': 0.45, + 'labels': list([ + '35%', + '40%', + '45%', + '50%', + '55%', + '60%', + '65%', + ]), + 'options': list([ + dict({ + 'label': '35%', + 'value': 0.35, + }), + dict({ + 'label': '40%', + 'value': 0.4, + }), + dict({ + 'label': '45%', + 'value': 0.45, + }), + dict({ + 'label': '50%', + 'value': 0.5, + }), + dict({ + 'label': '55%', + 'value': 0.55, + }), + dict({ + 'label': '60%', + 'value': 0.6, + }), + dict({ + 'label': '65%', + 'value': 0.65, + }), + ]), + 'title': 'Cooling Dehumidify Set Point', + 'type': 'dehumidify', + 'values': list([ + 0.35, + 0.4, + 0.45, + 0.5, + 0.55, + 0.6, + 0.65, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2293892/scale', + }), + }), + 'current_value': 'f', + 'labels': list([ + 'F', + 'C', + ]), + 'options': list([ + dict({ + 'label': 'F', + 'value': 'f', + }), + dict({ + 'label': 'C', + 'value': 'c', + }), + ]), + 'title': 'Temperature Scale', + 'type': 'scale', + 'values': list([ + 'f', + 'c', + ]), + }), + ]), + 'status_secondary': None, + 'status_tertiary': None, + 'system_status': 'Cooling', + 'type': 'xxl_thermostat', + 'zones': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394133', + }), + }), + 'cooling_setpoint': 79, + 'current_zone_mode': 'AUTO', + 'features': list([ + dict({ + 'actions': dict({ + 'set_cool_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394133/setpoints', + }), + 'set_heat_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394133/setpoints', + }), + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool': 79, + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat': 63, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': 'Relieving Air', + 'status_icon': dict({ + 'modifiers': list([ + ]), + 'name': 'cooling', + }), + 'system_status': 'Cooling', + 'temperature': 73, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394133/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'AUTO', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394133/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394133/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83394133', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83394133', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83394133', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83394133&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-73', + ]), + 'name': 'thermostat', + }), + 'id': 83394133, + 'name': 'Bath Closet', + 'operating_state': 'Relieving Air', + 'setpoints': dict({ + 'cool': 79, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394133/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394133/zone_mode', + }), + }), + 'current_value': 'AUTO', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394133/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394133/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 73, + 'type': 'xxl_zone', + 'zone_status': 'Relieving Air', + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394130', + }), + }), + 'cooling_setpoint': 71, + 'current_zone_mode': 'AUTO', + 'features': list([ + dict({ + 'actions': dict({ + 'set_cool_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394130/setpoints', + }), + 'set_heat_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394130/setpoints', + }), + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool': 71, + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat': 63, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': 'Damper Open', + 'status_icon': dict({ + 'modifiers': list([ + ]), + 'name': 'cooling', + }), + 'system_status': 'Cooling', + 'temperature': 74, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394130/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'AUTO', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394130/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394130/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83394130', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83394130', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83394130', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83394130&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-74', + ]), + 'name': 'thermostat', + }), + 'id': 83394130, + 'name': 'Master', + 'operating_state': 'Damper Open', + 'setpoints': dict({ + 'cool': 71, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394130/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394130/zone_mode', + }), + }), + 'current_value': 'AUTO', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394130/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394130/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 74, + 'type': 'xxl_zone', + 'zone_status': 'Damper Open', + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394136', + }), + }), + 'cooling_setpoint': 79, + 'current_zone_mode': 'AUTO', + 'features': list([ + dict({ + 'actions': dict({ + 'set_cool_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394136/setpoints', + }), + 'set_heat_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394136/setpoints', + }), + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool': 79, + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat': 63, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': 'Relieving Air', + 'status_icon': dict({ + 'modifiers': list([ + ]), + 'name': 'cooling', + }), + 'system_status': 'Cooling', + 'temperature': 73, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394136/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'AUTO', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394136/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394136/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83394136', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83394136', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83394136', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83394136&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-73', + ]), + 'name': 'thermostat', + }), + 'id': 83394136, + 'name': 'Nick Office', + 'operating_state': 'Relieving Air', + 'setpoints': dict({ + 'cool': 79, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394136/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394136/zone_mode', + }), + }), + 'current_value': 'AUTO', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394136/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394136/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 73, + 'type': 'xxl_zone', + 'zone_status': 'Relieving Air', + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394127', + }), + }), + 'cooling_setpoint': 79, + 'current_zone_mode': 'AUTO', + 'features': list([ + dict({ + 'actions': dict({ + 'set_cool_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394127/setpoints', + }), + 'set_heat_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394127/setpoints', + }), + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool': 79, + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat': 63, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': 'Damper Closed', + 'status_icon': dict({ + 'modifiers': list([ + ]), + 'name': 'cooling', + }), + 'system_status': 'Cooling', + 'temperature': 72, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394127/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'AUTO', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394127/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394127/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83394127', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83394127', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83394127', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83394127&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-72', + ]), + 'name': 'thermostat', + }), + 'id': 83394127, + 'name': 'Snooze Room', + 'operating_state': 'Damper Closed', + 'setpoints': dict({ + 'cool': 79, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394127/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394127/zone_mode', + }), + }), + 'current_value': 'AUTO', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394127/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394127/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 72, + 'type': 'xxl_zone', + 'zone_status': 'Damper Closed', + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394139', + }), + }), + 'cooling_setpoint': 79, + 'current_zone_mode': 'AUTO', + 'features': list([ + dict({ + 'actions': dict({ + 'set_cool_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394139/setpoints', + }), + 'set_heat_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394139/setpoints', + }), + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool': 79, + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat': 63, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': 'Damper Closed', + 'status_icon': dict({ + 'modifiers': list([ + ]), + 'name': 'cooling', + }), + 'system_status': 'Cooling', + 'temperature': 74, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394139/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'AUTO', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394139/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394139/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83394139', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83394139', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83394139', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83394139&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-74', + ]), + 'name': 'thermostat', + }), + 'id': 83394139, + 'name': 'Safe Room', + 'operating_state': 'Damper Closed', + 'setpoints': dict({ + 'cool': 79, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394139/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394139/zone_mode', + }), + }), + 'current_value': 'AUTO', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394139/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394139/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 74, + 'type': 'xxl_zone', + 'zone_status': 'Damper Closed', + }), + ]), + }), + dict({ + '_links': dict({ + 'filter_events': dict({ + 'href': 'https://www.mynexia.com/mobile/houses/123456/events/collection?sys_guid=3679e95b-7337-48ae-aff4-e0522e9dd0eb', + }), + 'nexia:history': dict({ + 'href': 'https://www.mynexia.com/mobile/houses/123456/events?device_id=2059652', + }), + 'pending_request': dict({ + 'polling_path': 'https://www.mynexia.com/backstage/announcements/c6627726f6339d104ee66897028d6a2ea38215675b336650', + }), + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2059652', + }), + }), + 'connected': True, + 'delta': 3, + 'features': list([ + dict({ + 'items': list([ + dict({ + 'label': 'Model', + 'type': 'label_value', + 'value': 'XL1050', + }), + dict({ + 'label': 'AUID', + 'type': 'label_value', + 'value': '02853DF0', + }), + dict({ + 'label': 'Firmware Build Number', + 'type': 'label_value', + 'value': '1581321824', + }), + dict({ + 'label': 'Firmware Build Date', + 'type': 'label_value', + 'value': '2020-02-10 08:03:44 UTC', + }), + dict({ + 'label': 'Firmware Version', + 'type': 'label_value', + 'value': '5.9.1', + }), + dict({ + 'label': 'Zoning Enabled', + 'type': 'label_value', + 'value': 'yes', + }), + ]), + 'name': 'advanced_info', + }), + dict({ + 'actions': dict({ + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': 'System Idle', + 'status_icon': None, + 'temperature': 77, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'members': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260991', + }), + }), + 'cooling_setpoint': 80, + 'current_zone_mode': 'OFF', + 'features': list([ + dict({ + 'actions': dict({ + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': '', + 'status_icon': None, + 'system_status': 'System Idle', + 'temperature': 77, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260991/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Off', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'OFF', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260991/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260991/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83260991', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83260991', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83260991', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83260991&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-77', + ]), + 'name': 'thermostat', + }), + 'id': 83260991, + 'name': 'Hallway', + 'operating_state': '', + 'setpoints': dict({ + 'cool': 80, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260991/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260991/zone_mode', + }), + }), + 'current_value': 'OFF', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260991/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260991/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 77, + 'type': 'xxl_zone', + 'zone_status': '', + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260994', + }), + }), + 'cooling_setpoint': 81, + 'current_zone_mode': 'AUTO', + 'features': list([ + dict({ + 'actions': dict({ + 'set_cool_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260994/setpoints', + }), + 'set_heat_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260994/setpoints', + }), + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool': 81, + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat': 63, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': '', + 'status_icon': None, + 'system_status': 'System Idle', + 'temperature': 74, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260994/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'AUTO', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260994/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260994/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83260994', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83260994', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83260994', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83260994&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-74', + ]), + 'name': 'thermostat', + }), + 'id': 83260994, + 'name': 'Mid Bedroom', + 'operating_state': '', + 'setpoints': dict({ + 'cool': 81, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260994/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260994/zone_mode', + }), + }), + 'current_value': 'AUTO', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260994/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260994/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 74, + 'type': 'xxl_zone', + 'zone_status': '', + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260997', + }), + }), + 'cooling_setpoint': 81, + 'current_zone_mode': 'AUTO', + 'features': list([ + dict({ + 'actions': dict({ + 'set_cool_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260997/setpoints', + }), + 'set_heat_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260997/setpoints', + }), + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool': 81, + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat': 63, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': '', + 'status_icon': None, + 'system_status': 'System Idle', + 'temperature': 75, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260997/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'AUTO', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260997/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260997/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83260997', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83260997', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83260997', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83260997&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-75', + ]), + 'name': 'thermostat', + }), + 'id': 83260997, + 'name': 'West Bedroom', + 'operating_state': '', + 'setpoints': dict({ + 'cool': 81, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260997/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260997/zone_mode', + }), + }), + 'current_value': 'AUTO', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260997/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260997/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 75, + 'type': 'xxl_zone', + 'zone_status': '', + }), + ]), + 'name': 'group', + }), + dict({ + 'actions': dict({ + 'update_thermostat_fan_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2059652/fan_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Fan Mode', + 'name': 'thermostat_fan_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_fan_mode', + 'label': 'Fan Mode', + 'value': 'thermostat_fan_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'auto', + }), + dict({ + 'label': 'On', + 'value': 'on', + }), + dict({ + 'label': 'Circulate', + 'value': 'circulate', + }), + ]), + 'status_icon': dict({ + 'modifiers': list([ + ]), + 'name': 'thermostat_fan_off', + }), + 'value': 'auto', + }), + dict({ + 'compressor_speed': 0.0, + 'name': 'thermostat_compressor_speed', + }), + dict({ + 'actions': dict({ + 'get_monthly_runtime_history': dict({ + 'href': 'https://www.mynexia.com/mobile/runtime_history/2059652?report_type=monthly', + 'method': 'GET', + }), + 'get_runtime_history': dict({ + 'href': 'https://www.mynexia.com/mobile/runtime_history/2059652?report_type=daily', + 'method': 'GET', + }), + }), + 'name': 'runtime_history', + }), + ]), + 'has_indoor_humidity': True, + 'has_outdoor_temperature': True, + 'icon': list([ + dict({ + 'modifiers': list([ + 'temperature-77', + ]), + 'name': 'thermostat', + }), + dict({ + 'modifiers': list([ + 'temperature-74', + ]), + 'name': 'thermostat', + }), + dict({ + 'modifiers': list([ + 'temperature-75', + ]), + 'name': 'thermostat', + }), + ]), + 'id': 2059652, + 'indoor_humidity': '37', + 'last_updated_at': '2020-03-11T15:15:53.000-05:00', + 'name': 'Upstairs West Wing', + 'name_editable': True, + 'outdoor_temperature': '87', + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2059652/fan_mode', + }), + }), + 'current_value': 'auto', + 'labels': list([ + 'Auto', + 'On', + 'Circulate', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'auto', + }), + dict({ + 'label': 'On', + 'value': 'on', + }), + dict({ + 'label': 'Circulate', + 'value': 'circulate', + }), + ]), + 'title': 'Fan Mode', + 'type': 'fan_mode', + 'values': list([ + 'auto', + 'on', + 'circulate', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2059652/fan_speed', + }), + }), + 'current_value': 0.35, + 'labels': list([ + '35%', + '40%', + '45%', + '50%', + '55%', + '60%', + '65%', + '70%', + '75%', + '80%', + '85%', + '90%', + '95%', + '100%', + ]), + 'options': list([ + dict({ + 'label': '35%', + 'value': 0.35, + }), + dict({ + 'label': '40%', + 'value': 0.4, + }), + dict({ + 'label': '45%', + 'value': 0.45, + }), + dict({ + 'label': '50%', + 'value': 0.5, + }), + dict({ + 'label': '55%', + 'value': 0.55, + }), + dict({ + 'label': '60%', + 'value': 0.6, + }), + dict({ + 'label': '65%', + 'value': 0.65, + }), + dict({ + 'label': '70%', + 'value': 0.7, + }), + dict({ + 'label': '75%', + 'value': 0.75, + }), + dict({ + 'label': '80%', + 'value': 0.8, + }), + dict({ + 'label': '85%', + 'value': 0.85, + }), + dict({ + 'label': '90%', + 'value': 0.9, + }), + dict({ + 'label': '95%', + 'value': 0.95, + }), + dict({ + 'label': '100%', + 'value': 1.0, + }), + ]), + 'title': 'Fan Speed', + 'type': 'fan_speed', + 'values': list([ + 0.35, + 0.4, + 0.45, + 0.5, + 0.55, + 0.6, + 0.65, + 0.7, + 0.75, + 0.8, + 0.85, + 0.9, + 0.95, + 1.0, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2059652/fan_circulation_time', + }), + }), + 'current_value': 30, + 'labels': list([ + '10 minutes', + '15 minutes', + '20 minutes', + '25 minutes', + '30 minutes', + '35 minutes', + '40 minutes', + '45 minutes', + '50 minutes', + '55 minutes', + ]), + 'options': list([ + dict({ + 'label': '10 minutes', + 'value': 10, + }), + dict({ + 'label': '15 minutes', + 'value': 15, + }), + dict({ + 'label': '20 minutes', + 'value': 20, + }), + dict({ + 'label': '25 minutes', + 'value': 25, + }), + dict({ + 'label': '30 minutes', + 'value': 30, + }), + dict({ + 'label': '35 minutes', + 'value': 35, + }), + dict({ + 'label': '40 minutes', + 'value': 40, + }), + dict({ + 'label': '45 minutes', + 'value': 45, + }), + dict({ + 'label': '50 minutes', + 'value': 50, + }), + dict({ + 'label': '55 minutes', + 'value': 55, + }), + ]), + 'title': 'Fan Circulation Time', + 'type': 'fan_circulation_time', + 'values': list([ + 10, + 15, + 20, + 25, + 30, + 35, + 40, + 45, + 50, + 55, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2059652/air_cleaner_mode', + }), + }), + 'current_value': 'auto', + 'labels': list([ + 'Auto', + 'Quick', + 'Allergy', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'auto', + }), + dict({ + 'label': 'Quick', + 'value': 'quick', + }), + dict({ + 'label': 'Allergy', + 'value': 'allergy', + }), + ]), + 'title': 'Air Cleaner Mode', + 'type': 'air_cleaner_mode', + 'values': list([ + 'auto', + 'quick', + 'allergy', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2059652/dehumidify', + }), + }), + 'current_value': 0.5, + 'labels': list([ + '35%', + '40%', + '45%', + '50%', + '55%', + '60%', + '65%', + ]), + 'options': list([ + dict({ + 'label': '35%', + 'value': 0.35, + }), + dict({ + 'label': '40%', + 'value': 0.4, + }), + dict({ + 'label': '45%', + 'value': 0.45, + }), + dict({ + 'label': '50%', + 'value': 0.5, + }), + dict({ + 'label': '55%', + 'value': 0.55, + }), + dict({ + 'label': '60%', + 'value': 0.6, + }), + dict({ + 'label': '65%', + 'value': 0.65, + }), + ]), + 'title': 'Cooling Dehumidify Set Point', + 'type': 'dehumidify', + 'values': list([ + 0.35, + 0.4, + 0.45, + 0.5, + 0.55, + 0.6, + 0.65, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2059652/scale', + }), + }), + 'current_value': 'f', + 'labels': list([ + 'F', + 'C', + ]), + 'options': list([ + dict({ + 'label': 'F', + 'value': 'f', + }), + dict({ + 'label': 'C', + 'value': 'c', + }), + ]), + 'title': 'Temperature Scale', + 'type': 'scale', + 'values': list([ + 'f', + 'c', + ]), + }), + ]), + 'status_secondary': None, + 'status_tertiary': None, + 'system_status': 'System Idle', + 'type': 'xxl_thermostat', + 'zones': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260991', + }), + }), + 'cooling_setpoint': 80, + 'current_zone_mode': 'OFF', + 'features': list([ + dict({ + 'actions': dict({ + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': '', + 'status_icon': None, + 'system_status': 'System Idle', + 'temperature': 77, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260991/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Off', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'OFF', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260991/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260991/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83260991', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83260991', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83260991', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83260991&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-77', + ]), + 'name': 'thermostat', + }), + 'id': 83260991, + 'name': 'Hallway', + 'operating_state': '', + 'setpoints': dict({ + 'cool': 80, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260991/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260991/zone_mode', + }), + }), + 'current_value': 'OFF', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260991/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260991/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 77, + 'type': 'xxl_zone', + 'zone_status': '', + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260994', + }), + }), + 'cooling_setpoint': 81, + 'current_zone_mode': 'AUTO', + 'features': list([ + dict({ + 'actions': dict({ + 'set_cool_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260994/setpoints', + }), + 'set_heat_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260994/setpoints', + }), + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool': 81, + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat': 63, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': '', + 'status_icon': None, + 'system_status': 'System Idle', + 'temperature': 74, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260994/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'AUTO', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260994/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260994/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83260994', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83260994', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83260994', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83260994&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-74', + ]), + 'name': 'thermostat', + }), + 'id': 83260994, + 'name': 'Mid Bedroom', + 'operating_state': '', + 'setpoints': dict({ + 'cool': 81, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260994/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260994/zone_mode', + }), + }), + 'current_value': 'AUTO', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260994/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260994/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 74, + 'type': 'xxl_zone', + 'zone_status': '', + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260997', + }), + }), + 'cooling_setpoint': 81, + 'current_zone_mode': 'AUTO', + 'features': list([ + dict({ + 'actions': dict({ + 'set_cool_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260997/setpoints', + }), + 'set_heat_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260997/setpoints', + }), + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool': 81, + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat': 63, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': '', + 'status_icon': None, + 'system_status': 'System Idle', + 'temperature': 75, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260997/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'AUTO', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260997/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260997/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83260997', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83260997', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83260997', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83260997&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-75', + ]), + 'name': 'thermostat', + }), + 'id': 83260997, + 'name': 'West Bedroom', + 'operating_state': '', + 'setpoints': dict({ + 'cool': 81, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260997/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260997/zone_mode', + }), + }), + 'current_value': 'AUTO', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260997/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260997/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 75, + 'type': 'xxl_zone', + 'zone_status': '', + }), + ]), + }), + ]), + 'entry': dict({ + 'brand': None, + 'title': 'Mock Title', + }), + }) +# --- diff --git a/tests/components/nexia/test_diagnostics.py b/tests/components/nexia/test_diagnostics.py index f58574098cc98b..9f8f7f05a8df9b 100644 --- a/tests/components/nexia/test_diagnostics.py +++ b/tests/components/nexia/test_diagnostics.py @@ -1,4 +1,6 @@ """Test august diagnostics.""" +from syrupy import SnapshotAssertion + from homeassistant.core import HomeAssistant from .util import async_init_integration @@ -8,9109 +10,12 @@ async def test_diagnostics( - hass: HomeAssistant, hass_client: ClientSessionGenerator + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test generating diagnostics for a config entry.""" entry = await async_init_integration(hass) diag = await get_diagnostics_for_config_entry(hass, hass_client, entry) - assert diag == { - "automations": [ - { - "_links": { - "edit": { - "href": ( - "https://www.mynexia.com/mobile" - "/automation_edit_buffers?automation_id=3467876" - ), - "method": "POST", - }, - "filter_events": { - "href": ( - "https://www.mynexia.com/mobile/houses/123456/events" - "/collection?sys_guid=472ae0d2-5d7c-4a1c-9e47-4d9035fdace5" - ) - }, - "nexia:history": { - "href": ( - "https://www.mynexia.com/mobile/houses/123456/events" - "?automation_id=3467876" - ) - }, - "self": { - "href": "https://www.mynexia.com/mobile/automations/3467876" - }, - }, - "description": ( - "When IFTTT activates the automation Upstairs " - "West Wing will permanently hold the heat to " - "62.0 and cool to 83.0 AND Downstairs East " - "Wing will permanently hold the heat to 62.0 " - "and cool to 83.0 AND Downstairs West Wing " - "will permanently hold the heat to 62.0 and " - "cool to 83.0 AND Activate the mode named " - "'Away 12' AND Master Suite will permanently " - "hold the heat to 62.0 and cool to 83.0" - ), - "enabled": True, - "icon": [ - {"modifiers": [], "name": "gears"}, - {"modifiers": [], "name": "climate"}, - {"modifiers": [], "name": "climate"}, - {"modifiers": [], "name": "climate"}, - {"modifiers": [], "name": "plane"}, - {"modifiers": [], "name": "climate"}, - ], - "id": 3467876, - "name": "Away for 12 Hours", - "settings": [], - "triggers": [], - }, - { - "_links": { - "edit": { - "href": ( - "https://www.mynexia.com/mobile" - "/automation_edit_buffers?automation_id=3467870" - ), - "method": "POST", - }, - "filter_events": { - "href": ( - "https://www.mynexia.com/mobile/houses/123456/events" - "/collection?sys_guid=f63ee20c-3146-49a1-87c5-47429a063d15" - ) - }, - "nexia:history": { - "href": ( - "https://www.mynexia.com/mobile/houses/123456" - "/events?automation_id=3467870" - ) - }, - "self": { - "href": "https://www.mynexia.com/mobile/automations/3467870" - }, - }, - "description": ( - "When IFTTT activates the automation Upstairs " - "West Wing will permanently hold the heat to " - "60.0 and cool to 85.0 AND Downstairs East " - "Wing will permanently hold the heat to 60.0 " - "and cool to 85.0 AND Downstairs West Wing " - "will permanently hold the heat to 60.0 and " - "cool to 85.0 AND Activate the mode named " - "'Away 24' AND Master Suite will permanently " - "hold the heat to 60.0 and cool to 85.0" - ), - "enabled": True, - "icon": [ - {"modifiers": [], "name": "gears"}, - {"modifiers": [], "name": "climate"}, - {"modifiers": [], "name": "climate"}, - {"modifiers": [], "name": "climate"}, - {"modifiers": [], "name": "plane"}, - {"modifiers": [], "name": "climate"}, - ], - "id": 3467870, - "name": "Away For 24 Hours", - "settings": [], - "triggers": [], - }, - { - "_links": { - "edit": { - "href": ( - "https://www.mynexia.com/mobile" - "/automation_edit_buffers?automation_id=3452469" - ), - "method": "POST", - }, - "filter_events": { - "href": ( - "https://www.mynexia.com/mobile/houses/123456/events" - "/collection?sys_guid=e5c59b93-efca-4937-9499-3f4c896ab17c" - ), - }, - "nexia:history": { - "href": ( - "https://www.mynexia.com/mobile/houses/123456" - "/events?automation_id=3452469" - ) - }, - "self": { - "href": "https://www.mynexia.com/mobile/automations/3452469" - }, - }, - "description": ( - "When IFTTT activates the automation Upstairs " - "West Wing will permanently hold the heat to " - "63.0 and cool to 80.0 AND Downstairs East " - "Wing will permanently hold the heat to 63.0 " - "and cool to 79.0 AND Downstairs West Wing " - "will permanently hold the heat to 63.0 and " - "cool to 79.0 AND Upstairs West Wing will " - "permanently hold the heat to 63.0 and cool " - "to 81.0 AND Upstairs West Wing will change " - "Fan Mode to Auto AND Downstairs East Wing " - "will change Fan Mode to Auto AND Downstairs " - "West Wing will change Fan Mode to Auto AND " - "Activate the mode named 'Away Short' AND " - "Master Suite will permanently hold the heat " - "to 63.0 and cool to 79.0 AND Master Suite " - "will change Fan Mode to Auto" - ), - "enabled": False, - "icon": [ - {"modifiers": [], "name": "gears"}, - {"modifiers": [], "name": "climate"}, - {"modifiers": [], "name": "climate"}, - {"modifiers": [], "name": "climate"}, - {"modifiers": [], "name": "climate"}, - {"modifiers": [], "name": "settings"}, - {"modifiers": [], "name": "settings"}, - {"modifiers": [], "name": "settings"}, - {"modifiers": [], "name": "key"}, - {"modifiers": [], "name": "climate"}, - {"modifiers": [], "name": "settings"}, - ], - "id": 3452469, - "name": "Away Short", - "settings": [], - "triggers": [], - }, - { - "_links": { - "edit": { - "href": ( - "https://www.mynexia.com/mobile" - "/automation_edit_buffers?automation_id=3452472" - ), - "method": "POST", - }, - "filter_events": { - "href": ( - "https://www.mynexia.com/mobile/houses/123456/events" - "/collection?sys_guid=861b9fec-d259-4492-a798-5712251666c4" - ), - }, - "nexia:history": { - "href": ( - "https://www.mynexia.com/mobile/houses/123456" - "/events?automation_id=3452472" - ), - }, - "self": { - "href": "https://www.mynexia.com/mobile/automations/3452472" - }, - }, - "description": ( - "When IFTTT activates the automation Upstairs " - "West Wing will Run Schedule AND Downstairs " - "East Wing will Run Schedule AND Downstairs " - "West Wing will Run Schedule AND Activate the " - "mode named 'Home' AND Master Suite will Run " - "Schedule" - ), - "enabled": True, - "icon": [ - {"modifiers": [], "name": "gears"}, - {"modifiers": [], "name": "settings"}, - {"modifiers": [], "name": "settings"}, - {"modifiers": [], "name": "settings"}, - {"modifiers": [], "name": "at_home"}, - {"modifiers": [], "name": "settings"}, - ], - "id": 3452472, - "name": "Home", - "settings": [], - "triggers": [], - }, - { - "_links": { - "edit": { - "href": ( - "https://www.mynexia.com/mobile" - "/automation_edit_buffers?automation_id=3454776" - ), - "method": "POST", - }, - "filter_events": { - "href": ( - "https://www.mynexia.com/mobile/houses/123456/events" - "/collection?sys_guid=96c71d37-66aa-4cbb-84ff-a90412fd366a" - ) - }, - "nexia:history": { - "href": ( - "https://www.mynexia.com/mobile/houses/123456" - "/events?automation_id=3454776" - ) - }, - "self": { - "href": "https://www.mynexia.com/mobile/automations/3454776" - }, - }, - "description": ( - "When IFTTT activates the automation Upstairs " - "West Wing will permanently hold the heat to " - "60.0 and cool to 85.0 AND Downstairs East " - "Wing will permanently hold the heat to 60.0 " - "and cool to 85.0 AND Downstairs West Wing " - "will permanently hold the heat to 60.0 and " - "cool to 85.0 AND Upstairs West Wing will " - "change Fan Mode to Auto AND Downstairs East " - "Wing will change Fan Mode to Auto AND " - "Downstairs West Wing will change Fan Mode to " - "Auto AND Master Suite will permanently hold " - "the heat to 60.0 and cool to 85.0 AND Master " - "Suite will change Fan Mode to Auto" - ), - "enabled": True, - "icon": [ - {"modifiers": [], "name": "gears"}, - {"modifiers": [], "name": "climate"}, - {"modifiers": [], "name": "climate"}, - {"modifiers": [], "name": "climate"}, - {"modifiers": [], "name": "settings"}, - {"modifiers": [], "name": "settings"}, - {"modifiers": [], "name": "settings"}, - {"modifiers": [], "name": "climate"}, - {"modifiers": [], "name": "settings"}, - ], - "id": 3454776, - "name": "IFTTT Power Spike", - "settings": [], - "triggers": [], - }, - { - "_links": { - "edit": { - "href": ( - "https://www.mynexia.com/mobile" - "/automation_edit_buffers?automation_id=3454774" - ), - "method": "POST", - }, - "filter_events": { - "href": ( - "https://www.mynexia.com/mobile/houses/123456/events" - "/collection?sys_guid=880c5287-d92c-4368-8494-e10975e92733" - ), - }, - "nexia:history": { - "href": ( - "https://www.mynexia.com/mobile/houses/123456" - "/events?automation_id=3454774" - ) - }, - "self": { - "href": "https://www.mynexia.com/mobile/automations/3454774" - }, - }, - "description": ( - "When IFTTT activates the automation Upstairs " - "West Wing will Run Schedule AND Downstairs " - "East Wing will Run Schedule AND Downstairs " - "West Wing will Run Schedule AND Master Suite " - "will Run Schedule" - ), - "enabled": False, - "icon": [ - {"modifiers": [], "name": "gears"}, - {"modifiers": [], "name": "settings"}, - {"modifiers": [], "name": "settings"}, - {"modifiers": [], "name": "settings"}, - {"modifiers": [], "name": "settings"}, - ], - "id": 3454774, - "name": "IFTTT return to schedule", - "settings": [], - "triggers": [], - }, - { - "_links": { - "edit": { - "href": ( - "https://www.mynexia.com/mobile" - "/automation_edit_buffers?automation_id=3486078" - ), - "method": "POST", - }, - "filter_events": { - "href": ( - "https://www.mynexia.com/mobile/houses/123456/events" - "/collection?sys_guid=d33c013b-2357-47a9-8c66-d2c3693173b0" - ) - }, - "nexia:history": { - "href": ( - "https://www.mynexia.com/mobile/houses/123456" - "/events?automation_id=3486078" - ) - }, - "self": { - "href": "https://www.mynexia.com/mobile/automations/3486078" - }, - }, - "description": ( - "When IFTTT activates the automation Upstairs " - "West Wing will permanently hold the heat to " - "55.0 and cool to 90.0 AND Downstairs East " - "Wing will permanently hold the heat to 55.0 " - "and cool to 90.0 AND Downstairs West Wing " - "will permanently hold the heat to 55.0 and " - "cool to 90.0 AND Activate the mode named " - "'Power Outage'" - ), - "enabled": True, - "icon": [ - {"modifiers": [], "name": "gears"}, - {"modifiers": [], "name": "climate"}, - {"modifiers": [], "name": "climate"}, - {"modifiers": [], "name": "climate"}, - {"modifiers": [], "name": "bell"}, - ], - "id": 3486078, - "name": "Power Outage", - "settings": [], - "triggers": [], - }, - { - "_links": { - "edit": { - "href": ( - "https://www.mynexia.com/mobile" - "/automation_edit_buffers?automation_id=3486091" - ), - "method": "POST", - }, - "filter_events": { - "href": ( - "https://www.mynexia.com/mobile/houses/123456/events" - "/collection?sys_guid=b9141df8-2e5e-4524-b8ef-efcbf48d775a" - ) - }, - "nexia:history": { - "href": ( - "https://www.mynexia.com/mobile/houses/123456" - "/events?automation_id=3486091" - ) - }, - "self": { - "href": "https://www.mynexia.com/mobile/automations/3486091" - }, - }, - "description": ( - "When IFTTT activates the automation Upstairs " - "West Wing will Run Schedule AND Downstairs " - "East Wing will Run Schedule AND Downstairs " - "West Wing will Run Schedule AND Activate the " - "mode named 'Home'" - ), - "enabled": True, - "icon": [ - {"modifiers": [], "name": "gears"}, - {"modifiers": [], "name": "settings"}, - {"modifiers": [], "name": "settings"}, - {"modifiers": [], "name": "settings"}, - {"modifiers": [], "name": "at_home"}, - ], - "id": 3486091, - "name": "Power Restored", - "settings": [], - "triggers": [], - }, - ], - "devices": [ - { - "_links": { - "filter_events": { - "href": ( - "https://www.mynexia.com/mobile/houses/123456/events" - "/collection?sys_guid=cd9a70e8-fd0d-4b58-b071-05a202fd8953" - ) - }, - "nexia:history": { - "href": ( - "https://www.mynexia.com/mobile/houses/123456" - "/events?device_id=2059661" - ) - }, - "pending_request": { - "polling_path": ( - "https://www.mynexia.com/backstage/announcements" - "/be6d8ede5cac02fe8be18c334b04d539c9200fa9230eef63" - ) - }, - "self": { - "href": "https://www.mynexia.com/mobile/xxl_thermostats/2059661" - }, - }, - "connected": True, - "delta": 3, - "features": [ - { - "items": [ - { - "label": "Model", - "type": "label_value", - "value": "XL1050", - }, - {"label": "AUID", "type": "label_value", "value": "000000"}, - { - "label": "Firmware Build Number", - "type": "label_value", - "value": "1581321824", - }, - { - "label": "Firmware Build Date", - "type": "label_value", - "value": "2020-02-10 08:03:44 UTC", - }, - { - "label": "Firmware Version", - "type": "label_value", - "value": "5.9.1", - }, - { - "label": "Zoning Enabled", - "type": "label_value", - "value": "yes", - }, - ], - "name": "advanced_info", - }, - { - "actions": {}, - "name": "thermostat", - "scale": "f", - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "System Idle", - "status_icon": None, - "temperature": 71, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "members": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones/83261002" - ) - } - }, - "cooling_setpoint": 79, - "current_zone_mode": "AUTO", - "features": [ - { - "actions": { - "set_cool_setpoint": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones/83261002/setpoints" - ) - }, - "set_heat_setpoint": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones/83261002/setpoints" - ) - }, - }, - "name": "thermostat", - "scale": "f", - "setpoint_cool": 79, - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat": 63, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "", - "status_icon": None, - "system_status": "System Idle", - "temperature": 71, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261002/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "AUTO", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261002/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": ( - "Follow or override the schedule." - ), - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83261002/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_active_schedule" - "?device_identifier" - "=XxlZone-83261002" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_default_schedule" - "?device_identifier" - "=XxlZone-83261002" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/set_active_schedule" - "?device_identifier" - "=XxlZone-83261002" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile" - "/schedules" - "?device_identifier=XxlZone-83261002" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": { - "modifiers": ["temperature-71"], - "name": "thermostat", - }, - "id": 83261002, - "name": "Living East", - "operating_state": "", - "setpoints": {"cool": 79, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261002" - "/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261002/zone_mode" - ) - } - }, - "current_value": "AUTO", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261002/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": [ - "Permanent Hold", - "Run Schedule", - ], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261002" - "/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 71, - "type": "xxl_zone", - "zone_status": "", - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261005" - ) - } - }, - "cooling_setpoint": 79, - "current_zone_mode": "AUTO", - "features": [ - { - "actions": { - "set_cool_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261005/setpoints" - ) - }, - "set_heat_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261005/setpoints" - ) - }, - }, - "name": "thermostat", - "scale": "f", - "setpoint_cool": 79, - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat": 63, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "", - "status_icon": None, - "system_status": "System Idle", - "temperature": 77, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261005/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "AUTO", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261005/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": ( - "Follow or override the schedule." - ), - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261005" - "/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_active_schedule" - "?device_identifier" - "=XxlZone-83261005" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_default_schedule" - "?device_identifier" - "=XxlZone-83261005" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/set_active_schedule" - "?device_identifier" - "=XxlZone-83261005" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile/schedules" - "?device_identifier=XxlZone-83261005" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": { - "modifiers": ["temperature-77"], - "name": "thermostat", - }, - "id": 83261005, - "name": "Kitchen", - "operating_state": "", - "setpoints": {"cool": 79, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261005" - "/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261005/zone_mode" - ) - } - }, - "current_value": "AUTO", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261005/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": [ - "Permanent Hold", - "Run Schedule", - ], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261005" - "/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 77, - "type": "xxl_zone", - "zone_status": "", - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261008" - ) - } - }, - "cooling_setpoint": 79, - "current_zone_mode": "AUTO", - "features": [ - { - "actions": { - "set_cool_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261008/setpoints" - ) - }, - "set_heat_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261008/setpoints" - ) - }, - }, - "name": "thermostat", - "scale": "f", - "setpoint_cool": 79, - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat": 63, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "", - "status_icon": None, - "system_status": "System Idle", - "temperature": 72, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261008/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "AUTO", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261008/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": ( - "Follow or override the schedule." - ), - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261008" - "/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_active_schedule" - "?device_identifier" - "=XxlZone-83261008" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_default_schedule" - "?device_identifier" - "=XxlZone-83261008" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/set_active_schedule" - "?device_identifier" - "=XxlZone-83261008" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile/schedules" - "?device_identifier=XxlZone-83261008" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": { - "modifiers": ["temperature-72"], - "name": "thermostat", - }, - "id": 83261008, - "name": "Down Bedroom", - "operating_state": "", - "setpoints": {"cool": 79, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261008" - "/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261008/zone_mode" - ) - } - }, - "current_value": "AUTO", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261008/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": [ - "Permanent Hold", - "Run Schedule", - ], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261008" - "/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 72, - "type": "xxl_zone", - "zone_status": "", - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261011" - ) - } - }, - "cooling_setpoint": 79, - "current_zone_mode": "AUTO", - "features": [ - { - "actions": { - "set_cool_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261011/setpoints" - ) - }, - "set_heat_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261011/setpoints" - ) - }, - }, - "name": "thermostat", - "scale": "f", - "setpoint_cool": 79, - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat": 63, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "", - "status_icon": None, - "system_status": "System Idle", - "temperature": 78, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261011/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "AUTO", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261011/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": ( - "Follow or override the schedule." - ), - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261011" - "/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_active_schedule" - "?device_identifier" - "=XxlZone-83261011" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_default_schedule" - "?device_identifier" - "=XxlZone-83261011" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/set_active_schedule" - "?device_identifier" - "=XxlZone-83261011" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile" - "/schedules" - "?device_identifier" - "=XxlZone-83261011" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": { - "modifiers": ["temperature-78"], - "name": "thermostat", - }, - "id": 83261011, - "name": "Tech Room", - "operating_state": "", - "setpoints": {"cool": 79, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261011" - "/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261011/zone_mode" - ) - } - }, - "current_value": "AUTO", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261011/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": [ - "Permanent Hold", - "Run Schedule", - ], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261011" - "/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 78, - "type": "xxl_zone", - "zone_status": "", - }, - ], - "name": "group", - }, - { - "actions": { - "update_thermostat_fan_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_thermostats/2059661/fan_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Fan Mode", - "name": "thermostat_fan_mode", - "options": [ - { - "header": True, - "id": "thermostat_fan_mode", - "label": "Fan Mode", - "value": "thermostat_fan_mode", - }, - {"label": "Auto", "value": "auto"}, - {"label": "On", "value": "on"}, - {"label": "Circulate", "value": "circulate"}, - ], - "status_icon": {"modifiers": [], "name": "thermostat_fan_off"}, - "value": "auto", - }, - {"compressor_speed": 0.0, "name": "thermostat_compressor_speed"}, - { - "actions": { - "get_monthly_runtime_history": { - "href": ( - "https://www.mynexia.com/mobile" - "/runtime_history/2059661?report_type=monthly" - ), - "method": "GET", - }, - "get_runtime_history": { - "href": ( - "https://www.mynexia.com/mobile" - "/runtime_history/2059661?report_type=daily" - ), - "method": "GET", - }, - }, - "name": "runtime_history", - }, - ], - "has_indoor_humidity": True, - "has_outdoor_temperature": True, - "icon": [ - {"modifiers": ["temperature-71"], "name": "thermostat"}, - {"modifiers": ["temperature-77"], "name": "thermostat"}, - {"modifiers": ["temperature-72"], "name": "thermostat"}, - {"modifiers": ["temperature-78"], "name": "thermostat"}, - ], - "id": 2059661, - "indoor_humidity": "36", - "last_updated_at": "2020-03-11T15:15:53.000-05:00", - "name": "Downstairs East Wing", - "name_editable": True, - "outdoor_temperature": "88", - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_thermostats/2059661/fan_mode" - ) - } - }, - "current_value": "auto", - "labels": ["Auto", "On", "Circulate"], - "options": [ - {"label": "Auto", "value": "auto"}, - {"label": "On", "value": "on"}, - {"label": "Circulate", "value": "circulate"}, - ], - "title": "Fan Mode", - "type": "fan_mode", - "values": ["auto", "on", "circulate"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_thermostats/2059661/fan_speed" - ) - } - }, - "current_value": 0.35, - "labels": [ - "35%", - "40%", - "45%", - "50%", - "55%", - "60%", - "65%", - "70%", - "75%", - "80%", - "85%", - "90%", - "95%", - "100%", - ], - "options": [ - {"label": "35%", "value": 0.35}, - {"label": "40%", "value": 0.4}, - {"label": "45%", "value": 0.45}, - {"label": "50%", "value": 0.5}, - {"label": "55%", "value": 0.55}, - {"label": "60%", "value": 0.6}, - {"label": "65%", "value": 0.65}, - {"label": "70%", "value": 0.7}, - {"label": "75%", "value": 0.75}, - {"label": "80%", "value": 0.8}, - {"label": "85%", "value": 0.85}, - {"label": "90%", "value": 0.9}, - {"label": "95%", "value": 0.95}, - {"label": "100%", "value": 1.0}, - ], - "title": "Fan Speed", - "type": "fan_speed", - "values": [ - 0.35, - 0.4, - 0.45, - 0.5, - 0.55, - 0.6, - 0.65, - 0.7, - 0.75, - 0.8, - 0.85, - 0.9, - 0.95, - 1.0, - ], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_thermostats/2059661" - "/fan_circulation_time" - ) - } - }, - "current_value": 30, - "labels": [ - "10 minutes", - "15 minutes", - "20 minutes", - "25 minutes", - "30 minutes", - "35 minutes", - "40 minutes", - "45 minutes", - "50 minutes", - "55 minutes", - ], - "options": [ - {"label": "10 minutes", "value": 10}, - {"label": "15 minutes", "value": 15}, - {"label": "20 minutes", "value": 20}, - {"label": "25 minutes", "value": 25}, - {"label": "30 minutes", "value": 30}, - {"label": "35 minutes", "value": 35}, - {"label": "40 minutes", "value": 40}, - {"label": "45 minutes", "value": 45}, - {"label": "50 minutes", "value": 50}, - {"label": "55 minutes", "value": 55}, - ], - "title": "Fan Circulation Time", - "type": "fan_circulation_time", - "values": [10, 15, 20, 25, 30, 35, 40, 45, 50, 55], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_thermostats/2059661/air_cleaner_mode" - ) - } - }, - "current_value": "auto", - "labels": ["Auto", "Quick", "Allergy"], - "options": [ - {"label": "Auto", "value": "auto"}, - {"label": "Quick", "value": "quick"}, - {"label": "Allergy", "value": "allergy"}, - ], - "title": "Air Cleaner Mode", - "type": "air_cleaner_mode", - "values": ["auto", "quick", "allergy"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_thermostats/2059661/dehumidify" - ) - } - }, - "current_value": 0.5, - "labels": ["35%", "40%", "45%", "50%", "55%", "60%", "65%"], - "options": [ - {"label": "35%", "value": 0.35}, - {"label": "40%", "value": 0.4}, - {"label": "45%", "value": 0.45}, - {"label": "50%", "value": 0.5}, - {"label": "55%", "value": 0.55}, - {"label": "60%", "value": 0.6}, - {"label": "65%", "value": 0.65}, - ], - "title": "Cooling Dehumidify Set Point", - "type": "dehumidify", - "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/2059661/scale" - ) - } - }, - "current_value": "f", - "labels": ["F", "C"], - "options": [ - {"label": "F", "value": "f"}, - {"label": "C", "value": "c"}, - ], - "title": "Temperature Scale", - "type": "scale", - "values": ["f", "c"], - }, - ], - "status_secondary": None, - "status_tertiary": None, - "system_status": "System Idle", - "type": "xxl_thermostat", - "zones": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones/83261002" - ) - } - }, - "cooling_setpoint": 79, - "current_zone_mode": "AUTO", - "features": [ - { - "actions": { - "set_cool_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261002/setpoints" - ) - }, - "set_heat_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261002/setpoints" - ) - }, - }, - "name": "thermostat", - "scale": "f", - "setpoint_cool": 79, - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat": 63, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "", - "status_icon": None, - "system_status": "System Idle", - "temperature": 71, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261002/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "AUTO", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261002/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": "Follow or override the schedule.", - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261002/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules/get_active_schedule" - "?device_identifier=XxlZone-83261002" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules/get_default_schedule" - "?device_identifier=XxlZone-83261002" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/set_active_schedule" - "?device_identifier=XxlZone-83261002" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile/schedules" - "?device_identifier=XxlZone-83261002" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": {"modifiers": ["temperature-71"], "name": "thermostat"}, - "id": 83261002, - "name": "Living East", - "operating_state": "", - "setpoints": {"cool": 79, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83261002/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261002/zone_mode" - ) - } - }, - "current_value": "AUTO", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261002/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": ["Permanent Hold", "Run Schedule"], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261002/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 71, - "type": "xxl_zone", - "zone_status": "", - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones/83261005" - ) - } - }, - "cooling_setpoint": 79, - "current_zone_mode": "AUTO", - "features": [ - { - "actions": { - "set_cool_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261005/setpoints" - ) - }, - "set_heat_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261005/setpoints" - ) - }, - }, - "name": "thermostat", - "scale": "f", - "setpoint_cool": 79, - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat": 63, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "", - "status_icon": None, - "system_status": "System Idle", - "temperature": 77, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261005/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "AUTO", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261005/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": "Follow or override the schedule.", - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83261005/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_active_schedule" - "?device_identifier=XxlZone-83261005" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_default_schedule" - "?device_identifier=XxlZone-83261005" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/set_active_schedule" - "?device_identifier=XxlZone-83261005" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile/schedules" - "?device_identifier=XxlZone-83261005" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": {"modifiers": ["temperature-77"], "name": "thermostat"}, - "id": 83261005, - "name": "Kitchen", - "operating_state": "", - "setpoints": {"cool": 79, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261005/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261005/zone_mode" - ) - } - }, - "current_value": "AUTO", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261005/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": ["Permanent Hold", "Run Schedule"], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261005/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 77, - "type": "xxl_zone", - "zone_status": "", - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones/83261008" - ) - } - }, - "cooling_setpoint": 79, - "current_zone_mode": "AUTO", - "features": [ - { - "actions": { - "set_cool_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261008/setpoints" - ) - }, - "set_heat_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261008/setpoints" - ) - }, - }, - "name": "thermostat", - "scale": "f", - "setpoint_cool": 79, - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat": 63, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "", - "status_icon": None, - "system_status": "System Idle", - "temperature": 72, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261008/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "AUTO", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261008/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": "Follow or override the schedule.", - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261008/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules/get_active_schedule" - "?device_identifier=XxlZone-83261008" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules/get_default_schedule" - "?device_identifier=XxlZone-83261008" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules/set_active_schedule" - "?device_identifier=XxlZone-83261008" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile/schedules" - "?device_identifier=XxlZone-83261008" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": {"modifiers": ["temperature-72"], "name": "thermostat"}, - "id": 83261008, - "name": "Down Bedroom", - "operating_state": "", - "setpoints": {"cool": 79, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83261008/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83261008/zone_mode" - ) - } - }, - "current_value": "AUTO", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83261008/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": ["Permanent Hold", "Run Schedule"], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83261008/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 72, - "type": "xxl_zone", - "zone_status": "", - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones/83261011" - ) - } - }, - "cooling_setpoint": 79, - "current_zone_mode": "AUTO", - "features": [ - { - "actions": { - "set_cool_setpoint": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83261011/setpoints" - ) - }, - "set_heat_setpoint": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83261011/setpoints" - ) - }, - }, - "name": "thermostat", - "scale": "f", - "setpoint_cool": 79, - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat": 63, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "", - "status_icon": None, - "system_status": "System Idle", - "temperature": 78, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83261011/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "AUTO", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83261011/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": "Follow or override the schedule.", - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83261011/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules/get_active_schedule" - "?device_identifier=XxlZone-83261011" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules/get_default_schedule" - "?device_identifier=XxlZone-83261011" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules/set_active_schedule" - "?device_identifier=XxlZone-83261011" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile/schedules" - "?device_identifier=XxlZone-83261011" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": {"modifiers": ["temperature-78"], "name": "thermostat"}, - "id": 83261011, - "name": "Tech Room", - "operating_state": "", - "setpoints": {"cool": 79, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83261011/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83261011/zone_mode" - ) - } - }, - "current_value": "AUTO", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83261011/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": ["Permanent Hold", "Run Schedule"], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83261011/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 78, - "type": "xxl_zone", - "zone_status": "", - }, - ], - }, - { - "_links": { - "filter_events": { - "href": ( - "https://www.mynexia.com/mobile/houses/123456/events" - "/collection?sys_guid=5aae72a6-1bd0-4d84-9bfd-673e7bc4907c" - ) - }, - "nexia:history": { - "href": ( - "https://www.mynexia.com/mobile/houses/123456" - "/events?device_id=2059676" - ) - }, - "pending_request": { - "polling_path": ( - "https://www.mynexia.com/backstage/announcements" - "/3412f1d96eb0c5edb5466c3c0598af60c06f8443f21e9bcb" - ) - }, - "self": { - "href": "https://www.mynexia.com/mobile/xxl_thermostats/2059676" - }, - }, - "connected": True, - "delta": 3, - "features": [ - { - "items": [ - { - "label": "Model", - "type": "label_value", - "value": "XL1050", - }, - { - "label": "AUID", - "type": "label_value", - "value": "02853E08", - }, - { - "label": "Firmware Build Number", - "type": "label_value", - "value": "1581321824", - }, - { - "label": "Firmware Build Date", - "type": "label_value", - "value": "2020-02-10 08:03:44 UTC", - }, - { - "label": "Firmware Version", - "type": "label_value", - "value": "5.9.1", - }, - { - "label": "Zoning Enabled", - "type": "label_value", - "value": "yes", - }, - ], - "name": "advanced_info", - }, - { - "actions": {}, - "name": "thermostat", - "scale": "f", - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "System Idle", - "status_icon": None, - "temperature": 75, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "members": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261015" - ) - } - }, - "cooling_setpoint": 79, - "current_zone_mode": "AUTO", - "features": [ - { - "actions": { - "set_cool_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261015/setpoints" - ) - }, - "set_heat_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261015/setpoints" - ) - }, - }, - "name": "thermostat", - "scale": "f", - "setpoint_cool": 79, - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat": 63, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "", - "status_icon": None, - "system_status": "System Idle", - "temperature": 75, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261015/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "AUTO", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261015/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": ( - "Follow or override the schedule." - ), - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261015/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_active_schedule" - "?device_identifier" - "=XxlZone-83261015" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_default_schedule" - "?device_identifier" - "=XxlZone-83261015" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/set_active_schedule" - "?device_identifier" - "=XxlZone-83261015" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile/schedules" - "?device_identifier=XxlZone-83261015" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": { - "modifiers": ["temperature-75"], - "name": "thermostat", - }, - "id": 83261015, - "name": "Living West", - "operating_state": "", - "setpoints": {"cool": 79, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261015" - "/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261015/zone_mode" - ) - } - }, - "current_value": "AUTO", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261015/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": [ - "Permanent Hold", - "Run Schedule", - ], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261015" - "/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 75, - "type": "xxl_zone", - "zone_status": "", - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261018" - ) - } - }, - "cooling_setpoint": 79, - "current_zone_mode": "AUTO", - "features": [ - { - "actions": { - "set_cool_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261018/setpoints" - ) - }, - "set_heat_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261018/setpoints" - ) - }, - }, - "name": "thermostat", - "scale": "f", - "setpoint_cool": 79, - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat": 63, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "", - "status_icon": None, - "system_status": "System Idle", - "temperature": 75, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261018/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "AUTO", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261018/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": ( - "Follow or override the schedule." - ), - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261018" - "/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_active_schedule" - "?device_identifier" - "=XxlZone-83261018" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_default_schedule" - "?device_identifier" - "=XxlZone-83261018" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/set_active_schedule" - "?device_identifier" - "=XxlZone-83261018" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile/schedules" - "?device_identifier=XxlZone-83261018" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": { - "modifiers": ["temperature-75"], - "name": "thermostat", - }, - "id": 83261018, - "name": "David Office", - "operating_state": "", - "setpoints": {"cool": 79, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261018" - "/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261018/zone_mode" - ) - } - }, - "current_value": "AUTO", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261018/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": [ - "Permanent Hold", - "Run Schedule", - ], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261018" - "/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 75, - "type": "xxl_zone", - "zone_status": "", - }, - ], - "name": "group", - }, - { - "actions": { - "update_thermostat_fan_mode": { - "href": ( - "https://www.mynexia.com/mobile/xxl_thermostats" - "/2059676/fan_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Fan Mode", - "name": "thermostat_fan_mode", - "options": [ - { - "header": True, - "id": "thermostat_fan_mode", - "label": "Fan Mode", - "value": "thermostat_fan_mode", - }, - {"label": "Auto", "value": "auto"}, - {"label": "On", "value": "on"}, - {"label": "Circulate", "value": "circulate"}, - ], - "status_icon": {"modifiers": [], "name": "thermostat_fan_off"}, - "value": "auto", - }, - {"compressor_speed": 0.0, "name": "thermostat_compressor_speed"}, - { - "actions": { - "get_monthly_runtime_history": { - "href": ( - "https://www.mynexia.com/mobile/runtime_history" - "/2059676?report_type=monthly" - ), - "method": "GET", - }, - "get_runtime_history": { - "href": ( - "https://www.mynexia.com/mobile/runtime_history" - "/2059676?report_type=daily" - ), - "method": "GET", - }, - }, - "name": "runtime_history", - }, - ], - "has_indoor_humidity": True, - "has_outdoor_temperature": True, - "icon": [ - {"modifiers": ["temperature-75"], "name": "thermostat"}, - {"modifiers": ["temperature-75"], "name": "thermostat"}, - ], - "id": 2059676, - "indoor_humidity": "52", - "last_updated_at": "2020-03-11T15:15:53.000-05:00", - "name": "Downstairs West Wing", - "name_editable": True, - "outdoor_temperature": "88", - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_thermostats" - "/2059676/fan_mode" - ) - } - }, - "current_value": "auto", - "labels": ["Auto", "On", "Circulate"], - "options": [ - {"label": "Auto", "value": "auto"}, - {"label": "On", "value": "on"}, - {"label": "Circulate", "value": "circulate"}, - ], - "title": "Fan Mode", - "type": "fan_mode", - "values": ["auto", "on", "circulate"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_thermostats" - "/2059676/fan_speed" - ) - } - }, - "current_value": 0.35, - "labels": [ - "35%", - "40%", - "45%", - "50%", - "55%", - "60%", - "65%", - "70%", - "75%", - "80%", - "85%", - "90%", - "95%", - "100%", - ], - "options": [ - {"label": "35%", "value": 0.35}, - {"label": "40%", "value": 0.4}, - {"label": "45%", "value": 0.45}, - {"label": "50%", "value": 0.5}, - {"label": "55%", "value": 0.55}, - {"label": "60%", "value": 0.6}, - {"label": "65%", "value": 0.65}, - {"label": "70%", "value": 0.7}, - {"label": "75%", "value": 0.75}, - {"label": "80%", "value": 0.8}, - {"label": "85%", "value": 0.85}, - {"label": "90%", "value": 0.9}, - {"label": "95%", "value": 0.95}, - {"label": "100%", "value": 1.0}, - ], - "title": "Fan Speed", - "type": "fan_speed", - "values": [ - 0.35, - 0.4, - 0.45, - 0.5, - 0.55, - 0.6, - 0.65, - 0.7, - 0.75, - 0.8, - 0.85, - 0.9, - 0.95, - 1.0, - ], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_thermostats" - "/2059676/fan_circulation_time" - ) - } - }, - "current_value": 30, - "labels": [ - "10 minutes", - "15 minutes", - "20 minutes", - "25 minutes", - "30 minutes", - "35 minutes", - "40 minutes", - "45 minutes", - "50 minutes", - "55 minutes", - ], - "options": [ - {"label": "10 minutes", "value": 10}, - {"label": "15 minutes", "value": 15}, - {"label": "20 minutes", "value": 20}, - {"label": "25 minutes", "value": 25}, - {"label": "30 minutes", "value": 30}, - {"label": "35 minutes", "value": 35}, - {"label": "40 minutes", "value": 40}, - {"label": "45 minutes", "value": 45}, - {"label": "50 minutes", "value": 50}, - {"label": "55 minutes", "value": 55}, - ], - "title": "Fan Circulation Time", - "type": "fan_circulation_time", - "values": [10, 15, 20, 25, 30, 35, 40, 45, 50, 55], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_thermostats" - "/2059676/air_cleaner_mode" - ) - } - }, - "current_value": "auto", - "labels": ["Auto", "Quick", "Allergy"], - "options": [ - {"label": "Auto", "value": "auto"}, - {"label": "Quick", "value": "quick"}, - {"label": "Allergy", "value": "allergy"}, - ], - "title": "Air Cleaner Mode", - "type": "air_cleaner_mode", - "values": ["auto", "quick", "allergy"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_thermostats" - "/2059676/dehumidify" - ) - } - }, - "current_value": 0.45, - "labels": ["35%", "40%", "45%", "50%", "55%", "60%", "65%"], - "options": [ - {"label": "35%", "value": 0.35}, - {"label": "40%", "value": 0.4}, - {"label": "45%", "value": 0.45}, - {"label": "50%", "value": 0.5}, - {"label": "55%", "value": 0.55}, - {"label": "60%", "value": 0.6}, - {"label": "65%", "value": 0.65}, - ], - "title": "Cooling Dehumidify Set Point", - "type": "dehumidify", - "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" - "/2059676/scale" - ) - } - }, - "current_value": "f", - "labels": ["F", "C"], - "options": [ - {"label": "F", "value": "f"}, - {"label": "C", "value": "c"}, - ], - "title": "Temperature Scale", - "type": "scale", - "values": ["f", "c"], - }, - ], - "status_secondary": None, - "status_tertiary": None, - "system_status": "System Idle", - "type": "xxl_thermostat", - "zones": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones/83261015" - ) - } - }, - "cooling_setpoint": 79, - "current_zone_mode": "AUTO", - "features": [ - { - "actions": { - "set_cool_setpoint": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83261015/setpoints" - ) - }, - "set_heat_setpoint": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83261015/setpoints" - ) - }, - }, - "name": "thermostat", - "scale": "f", - "setpoint_cool": 79, - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat": 63, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "", - "status_icon": None, - "system_status": "System Idle", - "temperature": 75, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83261015/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "AUTO", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83261015/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": "Follow or override the schedule.", - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83261015/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules/get_active_schedule" - "?device_identifier=XxlZone-83261015" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules/get_default_schedule" - "?device_identifier=XxlZone-83261015" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules/set_active_schedule" - "?device_identifier=XxlZone-83261015" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile/schedules" - "?device_identifier=XxlZone-83261015" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": {"modifiers": ["temperature-75"], "name": "thermostat"}, - "id": 83261015, - "name": "Living West", - "operating_state": "", - "setpoints": {"cool": 79, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83261015/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261015/zone_mode" - ) - } - }, - "current_value": "AUTO", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261015/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": ["Permanent Hold", "Run Schedule"], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261015/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 75, - "type": "xxl_zone", - "zone_status": "", - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones/83261018" - ) - } - }, - "cooling_setpoint": 79, - "current_zone_mode": "AUTO", - "features": [ - { - "actions": { - "set_cool_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261018/setpoints" - ) - }, - "set_heat_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261018/setpoints" - ) - }, - }, - "name": "thermostat", - "scale": "f", - "setpoint_cool": 79, - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat": 63, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "", - "status_icon": None, - "system_status": "System Idle", - "temperature": 75, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261018/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "AUTO", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261018/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": "Follow or override the schedule.", - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83261018/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules/get_active_schedule" - "?device_identifier=XxlZone-83261018" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules/get_default_schedule" - "?device_identifier=XxlZone-83261018" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules/set_active_schedule" - "?device_identifier=XxlZone-83261018" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile/schedules" - "?device_identifier=XxlZone-83261018" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": {"modifiers": ["temperature-75"], "name": "thermostat"}, - "id": 83261018, - "name": "David Office", - "operating_state": "", - "setpoints": {"cool": 79, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261018/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261018/zone_mode" - ) - } - }, - "current_value": "AUTO", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261018/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": ["Permanent Hold", "Run Schedule"], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261018/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 75, - "type": "xxl_zone", - "zone_status": "", - }, - ], - }, - { - "_links": { - "filter_events": { - "href": ( - "https://www.mynexia.com/mobile/houses/123456/events" - "/collection?sys_guid=e3fc90c7-2885-4f57-ae76-99e9ec81eef0" - ) - }, - "nexia:history": { - "href": ( - "https://www.mynexia.com/mobile/houses/123456" - "/events?device_id=2293892" - ) - }, - "pending_request": { - "polling_path": ( - "https://www.mynexia.com/backstage/announcements" - "/967361e8aed874aa5230930fd0e0bbd8b653261e982a6e0e" - ) - }, - "self": { - "href": "https://www.mynexia.com/mobile/xxl_thermostats/2293892" - }, - }, - "connected": True, - "delta": 3, - "features": [ - { - "items": [ - { - "label": "Model", - "type": "label_value", - "value": "XL1050", - }, - { - "label": "AUID", - "type": "label_value", - "value": "0281B02C", - }, - { - "label": "Firmware Build Number", - "type": "label_value", - "value": "1581321824", - }, - { - "label": "Firmware Build Date", - "type": "label_value", - "value": "2020-02-10 08:03:44 UTC", - }, - { - "label": "Firmware Version", - "type": "label_value", - "value": "5.9.1", - }, - { - "label": "Zoning Enabled", - "type": "label_value", - "value": "yes", - }, - ], - "name": "advanced_info", - }, - { - "actions": {}, - "name": "thermostat", - "scale": "f", - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "Cooling", - "status_icon": {"modifiers": [], "name": "cooling"}, - "temperature": 73, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "members": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394133" - ) - } - }, - "cooling_setpoint": 79, - "current_zone_mode": "AUTO", - "features": [ - { - "actions": { - "set_cool_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394133/setpoints" - ) - }, - "set_heat_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394133/setpoints" - ) - }, - }, - "name": "thermostat", - "scale": "f", - "setpoint_cool": 79, - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat": 63, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "Relieving Air", - "status_icon": { - "modifiers": [], - "name": "cooling", - }, - "system_status": "Cooling", - "temperature": 73, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394133/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "AUTO", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394133/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": ( - "Follow or override the schedule." - ), - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394133/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_active_schedule" - "?device_identifier=XxlZone-83394133" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_default_schedule" - "?device_identifier=XxlZone-83394133" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules/set_active_schedule" - "?device_identifier=XxlZone-83394133" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile/schedules" - "?device_identifier=XxlZone-83394133" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": { - "modifiers": ["temperature-73"], - "name": "thermostat", - }, - "id": 83394133, - "name": "Bath Closet", - "operating_state": "Relieving Air", - "setpoints": {"cool": 79, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394133/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394133/zone_mode" - ) - } - }, - "current_value": "AUTO", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394133/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": [ - "Permanent Hold", - "Run Schedule", - ], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394133/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 73, - "type": "xxl_zone", - "zone_status": "Relieving Air", - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394130" - ) - } - }, - "cooling_setpoint": 71, - "current_zone_mode": "AUTO", - "features": [ - { - "actions": { - "set_cool_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394130/setpoints" - ) - }, - "set_heat_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394130/setpoints" - ) - }, - }, - "name": "thermostat", - "scale": "f", - "setpoint_cool": 71, - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat": 63, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "Damper Open", - "status_icon": { - "modifiers": [], - "name": "cooling", - }, - "system_status": "Cooling", - "temperature": 74, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394130/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "AUTO", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394130/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": ( - "Follow or override the schedule." - ), - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394130" - "/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_active_schedule" - "?device_identifier" - "=XxlZone-83394130" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_default_schedule" - "?device_identifier" - "=XxlZone-83394130" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/set_active_schedule" - "?device_identifier" - "=XxlZone-83394130" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile/schedules" - "?device_identifier=XxlZone-83394130" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": { - "modifiers": ["temperature-74"], - "name": "thermostat", - }, - "id": 83394130, - "name": "Master", - "operating_state": "Damper Open", - "setpoints": {"cool": 71, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394130" - "/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394130/zone_mode" - ) - } - }, - "current_value": "AUTO", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394130/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": [ - "Permanent Hold", - "Run Schedule", - ], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394130" - "/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 74, - "type": "xxl_zone", - "zone_status": "Damper Open", - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394136" - ) - } - }, - "cooling_setpoint": 79, - "current_zone_mode": "AUTO", - "features": [ - { - "actions": { - "set_cool_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394136/setpoints" - ) - }, - "set_heat_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394136/setpoints" - ) - }, - }, - "name": "thermostat", - "scale": "f", - "setpoint_cool": 79, - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat": 63, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "Relieving Air", - "status_icon": { - "modifiers": [], - "name": "cooling", - }, - "system_status": "Cooling", - "temperature": 73, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394136/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "AUTO", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394136/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": ( - "Follow or override the schedule." - ), - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394136" - "/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_active_schedule" - "?device_identifier" - "=XxlZone-83394136" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_default_schedule" - "?device_identifier" - "=XxlZone-83394136" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/set_active_schedule" - "?device_identifier" - "=XxlZone-83394136" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile/schedules" - "?device_identifier=XxlZone-83394136" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": { - "modifiers": ["temperature-73"], - "name": "thermostat", - }, - "id": 83394136, - "name": "Nick Office", - "operating_state": "Relieving Air", - "setpoints": {"cool": 79, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394136/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394136/zone_mode" - ) - } - }, - "current_value": "AUTO", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394136/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": [ - "Permanent Hold", - "Run Schedule", - ], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394136/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 73, - "type": "xxl_zone", - "zone_status": "Relieving Air", - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394127" - ) - } - }, - "cooling_setpoint": 79, - "current_zone_mode": "AUTO", - "features": [ - { - "actions": { - "set_cool_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394127/setpoints" - ) - }, - "set_heat_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394127/setpoints" - ) - }, - }, - "name": "thermostat", - "scale": "f", - "setpoint_cool": 79, - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat": 63, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "Damper Closed", - "status_icon": { - "modifiers": [], - "name": "cooling", - }, - "system_status": "Cooling", - "temperature": 72, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394127/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "AUTO", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394127/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": ( - "Follow or override the schedule." - ), - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394127" - "/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_active_schedule" - "?device_identifier" - "=XxlZone-83394127" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_default_schedule" - "?device_identifier" - "=XxlZone-83394127" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/set_active_schedule" - "?device_identifier" - "=XxlZone-83394127" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile/schedules" - "?device_identifier=XxlZone-83394127" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": { - "modifiers": ["temperature-72"], - "name": "thermostat", - }, - "id": 83394127, - "name": "Snooze Room", - "operating_state": "Damper Closed", - "setpoints": {"cool": 79, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394127" - "/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394127/zone_mode" - ) - } - }, - "current_value": "AUTO", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394127/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": [ - "Permanent Hold", - "Run Schedule", - ], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394127" - "/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 72, - "type": "xxl_zone", - "zone_status": "Damper Closed", - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394139" - ) - } - }, - "cooling_setpoint": 79, - "current_zone_mode": "AUTO", - "features": [ - { - "actions": { - "set_cool_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394139/setpoints" - ) - }, - "set_heat_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394139/setpoints" - ) - }, - }, - "name": "thermostat", - "scale": "f", - "setpoint_cool": 79, - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat": 63, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "Damper Closed", - "status_icon": { - "modifiers": [], - "name": "cooling", - }, - "system_status": "Cooling", - "temperature": 74, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394139/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "AUTO", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394139/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": ( - "Follow or override the schedule." - ), - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394139" - "/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_active_schedule" - "?device_identifier" - "=XxlZone-83394139" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_default_schedule" - "?device_identifier" - "=XxlZone-83394139" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/set_active_schedule" - "?device_identifier" - "=XxlZone-83394139" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile/schedules" - "?device_identifier=XxlZone-83394139" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": { - "modifiers": ["temperature-74"], - "name": "thermostat", - }, - "id": 83394139, - "name": "Safe Room", - "operating_state": "Damper Closed", - "setpoints": {"cool": 79, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394139" - "/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394139/zone_mode" - ) - } - }, - "current_value": "AUTO", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394139/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": [ - "Permanent Hold", - "Run Schedule", - ], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394139" - "/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 74, - "type": "xxl_zone", - "zone_status": "Damper Closed", - }, - ], - "name": "group", - }, - { - "actions": { - "update_thermostat_fan_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_thermostats/2293892/fan_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Fan Mode", - "name": "thermostat_fan_mode", - "options": [ - { - "header": True, - "id": "thermostat_fan_mode", - "label": "Fan Mode", - "value": "thermostat_fan_mode", - }, - {"label": "Auto", "value": "auto"}, - {"label": "On", "value": "on"}, - {"label": "Circulate", "value": "circulate"}, - ], - "status_icon": {"modifiers": [], "name": "thermostat_fan_on"}, - "value": "auto", - }, - {"compressor_speed": 0.69, "name": "thermostat_compressor_speed"}, - { - "actions": { - "get_monthly_runtime_history": { - "href": ( - "https://www.mynexia.com/mobile/runtime_history" - "/2293892?report_type=monthly" - ), - "method": "GET", - }, - "get_runtime_history": { - "href": ( - "https://www.mynexia.com/mobile/runtime_history" - "/2293892?report_type=daily" - ), - "method": "GET", - }, - }, - "name": "runtime_history", - }, - ], - "has_indoor_humidity": True, - "has_outdoor_temperature": True, - "icon": [ - {"modifiers": ["temperature-73"], "name": "thermostat"}, - {"modifiers": ["temperature-74"], "name": "thermostat"}, - {"modifiers": ["temperature-73"], "name": "thermostat"}, - {"modifiers": ["temperature-72"], "name": "thermostat"}, - {"modifiers": ["temperature-74"], "name": "thermostat"}, - ], - "id": 2293892, - "indoor_humidity": "52", - "last_updated_at": "2020-03-11T15:15:53.000-05:00", - "name": "Master Suite", - "name_editable": True, - "outdoor_temperature": "87", - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_thermostats" - "/2293892/fan_mode" - ) - } - }, - "current_value": "auto", - "labels": ["Auto", "On", "Circulate"], - "options": [ - {"label": "Auto", "value": "auto"}, - {"label": "On", "value": "on"}, - {"label": "Circulate", "value": "circulate"}, - ], - "title": "Fan Mode", - "type": "fan_mode", - "values": ["auto", "on", "circulate"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_thermostats" - "/2293892/fan_speed" - ) - } - }, - "current_value": 0.35, - "labels": [ - "35%", - "40%", - "45%", - "50%", - "55%", - "60%", - "65%", - "70%", - "75%", - "80%", - "85%", - "90%", - "95%", - "100%", - ], - "options": [ - {"label": "35%", "value": 0.35}, - {"label": "40%", "value": 0.4}, - {"label": "45%", "value": 0.45}, - {"label": "50%", "value": 0.5}, - {"label": "55%", "value": 0.55}, - {"label": "60%", "value": 0.6}, - {"label": "65%", "value": 0.65}, - {"label": "70%", "value": 0.7}, - {"label": "75%", "value": 0.75}, - {"label": "80%", "value": 0.8}, - {"label": "85%", "value": 0.85}, - {"label": "90%", "value": 0.9}, - {"label": "95%", "value": 0.95}, - {"label": "100%", "value": 1.0}, - ], - "title": "Fan Speed", - "type": "fan_speed", - "values": [ - 0.35, - 0.4, - 0.45, - 0.5, - 0.55, - 0.6, - 0.65, - 0.7, - 0.75, - 0.8, - 0.85, - 0.9, - 0.95, - 1.0, - ], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_thermostats" - "/2293892/fan_circulation_time" - ) - } - }, - "current_value": 30, - "labels": [ - "10 minutes", - "15 minutes", - "20 minutes", - "25 minutes", - "30 minutes", - "35 minutes", - "40 minutes", - "45 minutes", - "50 minutes", - "55 minutes", - ], - "options": [ - {"label": "10 minutes", "value": 10}, - {"label": "15 minutes", "value": 15}, - {"label": "20 minutes", "value": 20}, - {"label": "25 minutes", "value": 25}, - {"label": "30 minutes", "value": 30}, - {"label": "35 minutes", "value": 35}, - {"label": "40 minutes", "value": 40}, - {"label": "45 minutes", "value": 45}, - {"label": "50 minutes", "value": 50}, - {"label": "55 minutes", "value": 55}, - ], - "title": "Fan Circulation Time", - "type": "fan_circulation_time", - "values": [10, 15, 20, 25, 30, 35, 40, 45, 50, 55], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_thermostats" - "/2293892/air_cleaner_mode" - ) - } - }, - "current_value": "auto", - "labels": ["Auto", "Quick", "Allergy"], - "options": [ - {"label": "Auto", "value": "auto"}, - {"label": "Quick", "value": "quick"}, - {"label": "Allergy", "value": "allergy"}, - ], - "title": "Air Cleaner Mode", - "type": "air_cleaner_mode", - "values": ["auto", "quick", "allergy"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_thermostats/2293892/dehumidify" - ) - } - }, - "current_value": 0.45, - "labels": ["35%", "40%", "45%", "50%", "55%", "60%", "65%"], - "options": [ - {"label": "35%", "value": 0.35}, - {"label": "40%", "value": 0.4}, - {"label": "45%", "value": 0.45}, - {"label": "50%", "value": 0.5}, - {"label": "55%", "value": 0.55}, - {"label": "60%", "value": 0.6}, - {"label": "65%", "value": 0.65}, - ], - "title": "Cooling Dehumidify Set Point", - "type": "dehumidify", - "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/2293892/scale" - ) - } - }, - "current_value": "f", - "labels": ["F", "C"], - "options": [ - {"label": "F", "value": "f"}, - {"label": "C", "value": "c"}, - ], - "title": "Temperature Scale", - "type": "scale", - "values": ["f", "c"], - }, - ], - "status_secondary": None, - "status_tertiary": None, - "system_status": "Cooling", - "type": "xxl_thermostat", - "zones": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones/83394133" - ) - } - }, - "cooling_setpoint": 79, - "current_zone_mode": "AUTO", - "features": [ - { - "actions": { - "set_cool_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394133/setpoints" - ) - }, - "set_heat_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394133/setpoints" - ) - }, - }, - "name": "thermostat", - "scale": "f", - "setpoint_cool": 79, - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat": 63, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "Relieving Air", - "status_icon": {"modifiers": [], "name": "cooling"}, - "system_status": "Cooling", - "temperature": 73, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394133/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "AUTO", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394133/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": "Follow or override the schedule.", - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83394133/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_active_schedule" - "?device_identifier" - "=XxlZone-83394133" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_default_schedule" - "?device_identifier" - "=XxlZone-83394133" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/set_active_schedule" - "?device_identifier" - "=XxlZone-83394133" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile/schedules" - "?device_identifier=XxlZone-83394133" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": {"modifiers": ["temperature-73"], "name": "thermostat"}, - "id": 83394133, - "name": "Bath Closet", - "operating_state": "Relieving Air", - "setpoints": {"cool": 79, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394133/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394133/zone_mode" - ) - } - }, - "current_value": "AUTO", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394133/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": ["Permanent Hold", "Run Schedule"], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394133/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 73, - "type": "xxl_zone", - "zone_status": "Relieving Air", - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones/83394130" - ) - } - }, - "cooling_setpoint": 71, - "current_zone_mode": "AUTO", - "features": [ - { - "actions": { - "set_cool_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394130/setpoints" - ) - }, - "set_heat_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394130/setpoints" - ) - }, - }, - "name": "thermostat", - "scale": "f", - "setpoint_cool": 71, - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat": 63, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "Damper Open", - "status_icon": {"modifiers": [], "name": "cooling"}, - "system_status": "Cooling", - "temperature": 74, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394130/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "AUTO", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394130/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": "Follow or override the schedule.", - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394130" - "/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_active_schedule" - "?device_identifier" - "=XxlZone-83394130" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_default_schedule" - "?device_identifier" - "=XxlZone-83394130" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/set_active_schedule" - "?device_identifier" - "=XxlZone-83394130" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile/schedules" - "?device_identifier=XxlZone-83394130" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": {"modifiers": ["temperature-74"], "name": "thermostat"}, - "id": 83394130, - "name": "Master", - "operating_state": "Damper Open", - "setpoints": {"cool": 71, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394130/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394130/zone_mode" - ) - } - }, - "current_value": "AUTO", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394130/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": ["Permanent Hold", "Run Schedule"], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394130" - "/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 74, - "type": "xxl_zone", - "zone_status": "Damper Open", - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones/83394136" - ) - } - }, - "cooling_setpoint": 79, - "current_zone_mode": "AUTO", - "features": [ - { - "actions": { - "set_cool_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394136/setpoints" - ) - }, - "set_heat_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394136/setpoints" - ) - }, - }, - "name": "thermostat", - "scale": "f", - "setpoint_cool": 79, - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat": 63, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "Relieving Air", - "status_icon": {"modifiers": [], "name": "cooling"}, - "system_status": "Cooling", - "temperature": 73, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394136/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "AUTO", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394136/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": "Follow or override the schedule.", - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394136/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_active_schedule" - "?device_identifier" - "=XxlZone-83394136" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_default_schedule" - "?device_identifier" - "=XxlZone-83394136" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/set_active_schedule" - "?device_identifier" - "=XxlZone-83394136" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile/schedules" - "?device_identifier=XxlZone-83394136" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": {"modifiers": ["temperature-73"], "name": "thermostat"}, - "id": 83394136, - "name": "Nick Office", - "operating_state": "Relieving Air", - "setpoints": {"cool": 79, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394136/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394136/zone_mode" - ) - } - }, - "current_value": "AUTO", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394136/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": ["Permanent Hold", "Run Schedule"], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394136/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 73, - "type": "xxl_zone", - "zone_status": "Relieving Air", - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones/83394127" - ) - } - }, - "cooling_setpoint": 79, - "current_zone_mode": "AUTO", - "features": [ - { - "actions": { - "set_cool_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394127/setpoints" - ) - }, - "set_heat_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394127/setpoints" - ) - }, - }, - "name": "thermostat", - "scale": "f", - "setpoint_cool": 79, - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat": 63, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "Damper Closed", - "status_icon": {"modifiers": [], "name": "cooling"}, - "system_status": "Cooling", - "temperature": 72, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394127/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "AUTO", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394127/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": "Follow or override the schedule.", - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83394127/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules/get_active_schedule" - "?device_identifier=XxlZone-83394127" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules/get_default_schedule" - "?device_identifier=XxlZone-83394127" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules/set_active_schedule" - "?device_identifier=XxlZone-83394127" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile/schedules" - "?device_identifier=XxlZone-83394127" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": {"modifiers": ["temperature-72"], "name": "thermostat"}, - "id": 83394127, - "name": "Snooze Room", - "operating_state": "Damper Closed", - "setpoints": {"cool": 79, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394127/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394127/zone_mode" - ) - } - }, - "current_value": "AUTO", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394127/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": ["Permanent Hold", "Run Schedule"], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394127/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 72, - "type": "xxl_zone", - "zone_status": "Damper Closed", - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones/83394139" - ) - } - }, - "cooling_setpoint": 79, - "current_zone_mode": "AUTO", - "features": [ - { - "actions": { - "set_cool_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394139/setpoints" - ) - }, - "set_heat_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394139/setpoints" - ) - }, - }, - "name": "thermostat", - "scale": "f", - "setpoint_cool": 79, - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat": 63, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "Damper Closed", - "status_icon": {"modifiers": [], "name": "cooling"}, - "system_status": "Cooling", - "temperature": 74, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394139/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "AUTO", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394139/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": "Follow or override the schedule.", - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83394139/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules/get_active_schedule" - "?device_identifier=XxlZone-83394139" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules/get_default_schedule" - "?device_identifier=XxlZone-83394139" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules/set_active_schedule" - "?device_identifier=XxlZone-83394139" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile/schedules" - "?device_identifier=XxlZone-83394139" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": {"modifiers": ["temperature-74"], "name": "thermostat"}, - "id": 83394139, - "name": "Safe Room", - "operating_state": "Damper Closed", - "setpoints": {"cool": 79, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394139/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394139/zone_mode" - ) - } - }, - "current_value": "AUTO", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394139/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": ["Permanent Hold", "Run Schedule"], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394139/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 74, - "type": "xxl_zone", - "zone_status": "Damper Closed", - }, - ], - }, - { - "_links": { - "filter_events": { - "href": ( - "https://www.mynexia.com/mobile/houses/123456/events" - "/collection?sys_guid=3679e95b-7337-48ae-aff4-e0522e9dd0eb" - ) - }, - "nexia:history": { - "href": ( - "https://www.mynexia.com/mobile/houses/123456" - "/events?device_id=2059652" - ) - }, - "pending_request": { - "polling_path": ( - "https://www.mynexia.com/backstage/announcements" - "/c6627726f6339d104ee66897028d6a2ea38215675b336650" - ) - }, - "self": { - "href": "https://www.mynexia.com/mobile/xxl_thermostats/2059652" - }, - }, - "connected": True, - "delta": 3, - "features": [ - { - "items": [ - { - "label": "Model", - "type": "label_value", - "value": "XL1050", - }, - { - "label": "AUID", - "type": "label_value", - "value": "02853DF0", - }, - { - "label": "Firmware Build Number", - "type": "label_value", - "value": "1581321824", - }, - { - "label": "Firmware Build Date", - "type": "label_value", - "value": "2020-02-10 08:03:44 UTC", - }, - { - "label": "Firmware Version", - "type": "label_value", - "value": "5.9.1", - }, - { - "label": "Zoning Enabled", - "type": "label_value", - "value": "yes", - }, - ], - "name": "advanced_info", - }, - { - "actions": {}, - "name": "thermostat", - "scale": "f", - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "System Idle", - "status_icon": None, - "temperature": 77, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "members": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260991" - ) - } - }, - "cooling_setpoint": 80, - "current_zone_mode": "OFF", - "features": [ - { - "actions": {}, - "name": "thermostat", - "scale": "f", - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "", - "status_icon": None, - "system_status": "System Idle", - "temperature": 77, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260991/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Off", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "OFF", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260991/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": ( - "Follow or override the schedule." - ), - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260991" - "/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_active_schedule" - "?device_identifier" - "=XxlZone-83260991" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_default_schedule" - "?device_identifier" - "=XxlZone-83260991" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/set_active_schedule" - "?device_identifier" - "=XxlZone-83260991" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile/schedules" - "?device_identifier=XxlZone-83260991" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": { - "modifiers": ["temperature-77"], - "name": "thermostat", - }, - "id": 83260991, - "name": "Hallway", - "operating_state": "", - "setpoints": {"cool": 80, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260991/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260991/zone_mode" - ) - } - }, - "current_value": "OFF", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260991/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": [ - "Permanent Hold", - "Run Schedule", - ], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260991" - "/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 77, - "type": "xxl_zone", - "zone_status": "", - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260994" - ) - } - }, - "cooling_setpoint": 81, - "current_zone_mode": "AUTO", - "features": [ - { - "actions": { - "set_cool_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260994/setpoints" - ) - }, - "set_heat_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260994/setpoints" - ) - }, - }, - "name": "thermostat", - "scale": "f", - "setpoint_cool": 81, - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat": 63, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "", - "status_icon": None, - "system_status": "System Idle", - "temperature": 74, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260994/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "AUTO", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260994/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": ( - "Follow or override the schedule." - ), - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260994" - "/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_active_schedule" - "?device_identifier" - "=XxlZone-83260994" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_default_schedule" - "?device_identifier" - "=XxlZone-83260994" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/set_active_schedule" - "?device_identifier" - "=XxlZone-83260994" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile/schedules" - "?device_identifier=XxlZone-83260994" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": { - "modifiers": ["temperature-74"], - "name": "thermostat", - }, - "id": 83260994, - "name": "Mid Bedroom", - "operating_state": "", - "setpoints": {"cool": 81, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260994" - "/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260994/zone_mode" - ) - } - }, - "current_value": "AUTO", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260994/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": [ - "Permanent Hold", - "Run Schedule", - ], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260994" - "/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 74, - "type": "xxl_zone", - "zone_status": "", - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260997" - ) - } - }, - "cooling_setpoint": 81, - "current_zone_mode": "AUTO", - "features": [ - { - "actions": { - "set_cool_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260997/setpoints" - ) - }, - "set_heat_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260997/setpoints" - ) - }, - }, - "name": "thermostat", - "scale": "f", - "setpoint_cool": 81, - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat": 63, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "", - "status_icon": None, - "system_status": "System Idle", - "temperature": 75, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260997/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "AUTO", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260997/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": ( - "Follow or override the schedule." - ), - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260997" - "/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_active_schedule" - "?device_identifier" - "=XxlZone-83260997" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_default_schedule" - "?device_identifier" - "=XxlZone-83260997" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/set_active_schedule" - "?device_identifier" - "=XxlZone-83260997" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile/schedules" - "?device_identifier=XxlZone-83260997" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": { - "modifiers": ["temperature-75"], - "name": "thermostat", - }, - "id": 83260997, - "name": "West Bedroom", - "operating_state": "", - "setpoints": {"cool": 81, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260997/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260997/zone_mode" - ) - } - }, - "current_value": "AUTO", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260997/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": [ - "Permanent Hold", - "Run Schedule", - ], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260997/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 75, - "type": "xxl_zone", - "zone_status": "", - }, - ], - "name": "group", - }, - { - "actions": { - "update_thermostat_fan_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_thermostats/2059652/fan_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Fan Mode", - "name": "thermostat_fan_mode", - "options": [ - { - "header": True, - "id": "thermostat_fan_mode", - "label": "Fan Mode", - "value": "thermostat_fan_mode", - }, - {"label": "Auto", "value": "auto"}, - {"label": "On", "value": "on"}, - {"label": "Circulate", "value": "circulate"}, - ], - "status_icon": {"modifiers": [], "name": "thermostat_fan_off"}, - "value": "auto", - }, - {"compressor_speed": 0.0, "name": "thermostat_compressor_speed"}, - { - "actions": { - "get_monthly_runtime_history": { - "href": ( - "https://www.mynexia.com/mobile/runtime_history" - "/2059652?report_type=monthly" - ), - "method": "GET", - }, - "get_runtime_history": { - "href": ( - "https://www.mynexia.com/mobile/runtime_history" - "/2059652?report_type=daily" - ), - "method": "GET", - }, - }, - "name": "runtime_history", - }, - ], - "has_indoor_humidity": True, - "has_outdoor_temperature": True, - "icon": [ - {"modifiers": ["temperature-77"], "name": "thermostat"}, - {"modifiers": ["temperature-74"], "name": "thermostat"}, - {"modifiers": ["temperature-75"], "name": "thermostat"}, - ], - "id": 2059652, - "indoor_humidity": "37", - "last_updated_at": "2020-03-11T15:15:53.000-05:00", - "name": "Upstairs West Wing", - "name_editable": True, - "outdoor_temperature": "87", - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_thermostats/2059652/fan_mode" - ) - } - }, - "current_value": "auto", - "labels": ["Auto", "On", "Circulate"], - "options": [ - {"label": "Auto", "value": "auto"}, - {"label": "On", "value": "on"}, - {"label": "Circulate", "value": "circulate"}, - ], - "title": "Fan Mode", - "type": "fan_mode", - "values": ["auto", "on", "circulate"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_thermostats/2059652/fan_speed" - ) - } - }, - "current_value": 0.35, - "labels": [ - "35%", - "40%", - "45%", - "50%", - "55%", - "60%", - "65%", - "70%", - "75%", - "80%", - "85%", - "90%", - "95%", - "100%", - ], - "options": [ - {"label": "35%", "value": 0.35}, - {"label": "40%", "value": 0.4}, - {"label": "45%", "value": 0.45}, - {"label": "50%", "value": 0.5}, - {"label": "55%", "value": 0.55}, - {"label": "60%", "value": 0.6}, - {"label": "65%", "value": 0.65}, - {"label": "70%", "value": 0.7}, - {"label": "75%", "value": 0.75}, - {"label": "80%", "value": 0.8}, - {"label": "85%", "value": 0.85}, - {"label": "90%", "value": 0.9}, - {"label": "95%", "value": 0.95}, - {"label": "100%", "value": 1.0}, - ], - "title": "Fan Speed", - "type": "fan_speed", - "values": [ - 0.35, - 0.4, - 0.45, - 0.5, - 0.55, - 0.6, - 0.65, - 0.7, - 0.75, - 0.8, - 0.85, - 0.9, - 0.95, - 1.0, - ], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_thermostats/2059652" - "/fan_circulation_time" - ) - } - }, - "current_value": 30, - "labels": [ - "10 minutes", - "15 minutes", - "20 minutes", - "25 minutes", - "30 minutes", - "35 minutes", - "40 minutes", - "45 minutes", - "50 minutes", - "55 minutes", - ], - "options": [ - {"label": "10 minutes", "value": 10}, - {"label": "15 minutes", "value": 15}, - {"label": "20 minutes", "value": 20}, - {"label": "25 minutes", "value": 25}, - {"label": "30 minutes", "value": 30}, - {"label": "35 minutes", "value": 35}, - {"label": "40 minutes", "value": 40}, - {"label": "45 minutes", "value": 45}, - {"label": "50 minutes", "value": 50}, - {"label": "55 minutes", "value": 55}, - ], - "title": "Fan Circulation Time", - "type": "fan_circulation_time", - "values": [10, 15, 20, 25, 30, 35, 40, 45, 50, 55], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_thermostats/2059652/air_cleaner_mode" - ) - } - }, - "current_value": "auto", - "labels": ["Auto", "Quick", "Allergy"], - "options": [ - {"label": "Auto", "value": "auto"}, - {"label": "Quick", "value": "quick"}, - {"label": "Allergy", "value": "allergy"}, - ], - "title": "Air Cleaner Mode", - "type": "air_cleaner_mode", - "values": ["auto", "quick", "allergy"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_thermostats/2059652/dehumidify" - ) - } - }, - "current_value": 0.5, - "labels": ["35%", "40%", "45%", "50%", "55%", "60%", "65%"], - "options": [ - {"label": "35%", "value": 0.35}, - {"label": "40%", "value": 0.4}, - {"label": "45%", "value": 0.45}, - {"label": "50%", "value": 0.5}, - {"label": "55%", "value": 0.55}, - {"label": "60%", "value": 0.6}, - {"label": "65%", "value": 0.65}, - ], - "title": "Cooling Dehumidify Set Point", - "type": "dehumidify", - "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/2059652/scale" - ) - } - }, - "current_value": "f", - "labels": ["F", "C"], - "options": [ - {"label": "F", "value": "f"}, - {"label": "C", "value": "c"}, - ], - "title": "Temperature Scale", - "type": "scale", - "values": ["f", "c"], - }, - ], - "status_secondary": None, - "status_tertiary": None, - "system_status": "System Idle", - "type": "xxl_thermostat", - "zones": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones/83260991" - ) - } - }, - "cooling_setpoint": 80, - "current_zone_mode": "OFF", - "features": [ - { - "actions": {}, - "name": "thermostat", - "scale": "f", - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "", - "status_icon": None, - "system_status": "System Idle", - "temperature": 77, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260991/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Off", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "OFF", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260991/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": "Follow or override the schedule.", - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260991/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules/get_active_schedule" - "?device_identifier=XxlZone-83260991" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules/get_default_schedule" - "?device_identifier=XxlZone-83260991" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules/set_active_schedule" - "?device_identifier=XxlZone-83260991" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile/schedules" - "?device_identifier=XxlZone-83260991" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": {"modifiers": ["temperature-77"], "name": "thermostat"}, - "id": 83260991, - "name": "Hallway", - "operating_state": "", - "setpoints": {"cool": 80, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260991/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260991/zone_mode" - ) - } - }, - "current_value": "OFF", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260991/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": ["Permanent Hold", "Run Schedule"], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260991/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 77, - "type": "xxl_zone", - "zone_status": "", - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones/83260994" - ) - } - }, - "cooling_setpoint": 81, - "current_zone_mode": "AUTO", - "features": [ - { - "actions": { - "set_cool_setpoint": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83260994/setpoints" - ) - }, - "set_heat_setpoint": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83260994/setpoints" - ) - }, - }, - "name": "thermostat", - "scale": "f", - "setpoint_cool": 81, - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat": 63, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "", - "status_icon": None, - "system_status": "System Idle", - "temperature": 74, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83260994/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "AUTO", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83260994/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": "Follow or override the schedule.", - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83260994/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_active_schedule" - "?device_identifier=XxlZone-83260994" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_default_schedule" - "?device_identifier=XxlZone-83260994" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/set_active_schedule" - "?device_identifier=XxlZone-83260994" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile/schedules" - "?device_identifier=XxlZone-83260994" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": {"modifiers": ["temperature-74"], "name": "thermostat"}, - "id": 83260994, - "name": "Mid Bedroom", - "operating_state": "", - "setpoints": {"cool": 81, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83260994/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83260994/zone_mode" - ) - } - }, - "current_value": "AUTO", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83260994/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": ["Permanent Hold", "Run Schedule"], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83260994/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 74, - "type": "xxl_zone", - "zone_status": "", - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones/83260997" - ) - } - }, - "cooling_setpoint": 81, - "current_zone_mode": "AUTO", - "features": [ - { - "actions": { - "set_cool_setpoint": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83260997/setpoints" - ) - }, - "set_heat_setpoint": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83260997/setpoints" - ) - }, - }, - "name": "thermostat", - "scale": "f", - "setpoint_cool": 81, - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat": 63, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "", - "status_icon": None, - "system_status": "System Idle", - "temperature": 75, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83260997/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "AUTO", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260997/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": "Follow or override the schedule.", - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83260997/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_active_schedule" - "?device_identifier=XxlZone-83260997" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_default_schedule" - "?device_identifier=XxlZone-83260997" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/set_active_schedule" - "?device_identifier=XxlZone-83260997" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile/schedules" - "?device_identifier=XxlZone-83260997" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": {"modifiers": ["temperature-75"], "name": "thermostat"}, - "id": 83260997, - "name": "West Bedroom", - "operating_state": "", - "setpoints": {"cool": 81, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83260997/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83260997/zone_mode" - ) - } - }, - "current_value": "AUTO", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83260997/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": ["Permanent Hold", "Run Schedule"], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83260997/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 75, - "type": "xxl_zone", - "zone_status": "", - }, - ], - }, - ], - "entry": {"brand": None, "title": "Mock Title"}, - } + assert diag == snapshot diff --git a/tests/components/nextbus/conftest.py b/tests/components/nextbus/conftest.py new file mode 100644 index 00000000000000..a38f3fd850e151 --- /dev/null +++ b/tests/components/nextbus/conftest.py @@ -0,0 +1,36 @@ +"""Test helpers for NextBus tests.""" +from unittest.mock import MagicMock + +import pytest + + +@pytest.fixture +def mock_nextbus_lists(mock_nextbus: MagicMock) -> MagicMock: + """Mock all list functions in nextbus to test validate logic.""" + instance = mock_nextbus.return_value + instance.get_agency_list.return_value = { + "agency": [{"tag": "sf-muni", "title": "San Francisco Muni"}] + } + instance.get_route_list.return_value = { + "route": [{"tag": "F", "title": "F - Market & Wharves"}] + } + instance.get_route_config.return_value = { + "route": { + "stop": [ + {"tag": "5650", "title": "Market St & 7th St"}, + {"tag": "5651", "title": "Market St & 7th St"}, + ], + "direction": [ + { + "name": "Outbound", + "stop": [{"tag": "5650"}], + }, + { + "name": "Inbound", + "stop": [{"tag": "5651"}], + }, + ], + } + } + + return instance diff --git a/tests/components/nextbus/test_config_flow.py b/tests/components/nextbus/test_config_flow.py new file mode 100644 index 00000000000000..9f427757183595 --- /dev/null +++ b/tests/components/nextbus/test_config_flow.py @@ -0,0 +1,162 @@ +"""Test the NextBus config flow.""" +from collections.abc import Generator +from unittest.mock import MagicMock, patch + +import pytest + +from homeassistant import config_entries, setup +from homeassistant.components.nextbus.const import ( + CONF_AGENCY, + CONF_ROUTE, + CONF_STOP, + DOMAIN, +) +from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + + +@pytest.fixture +def mock_setup_entry() -> Generator[MagicMock, None, None]: + """Create a mock for the nextbus component setup.""" + with patch( + "homeassistant.components.nextbus.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_nextbus() -> Generator[MagicMock, None, None]: + """Create a mock py_nextbus module.""" + with patch("homeassistant.components.nextbus.config_flow.NextBusClient") as client: + yield client + + +async def test_import_config( + hass: HomeAssistant, mock_setup_entry: MagicMock, mock_nextbus_lists: MagicMock +) -> None: + """Test config is imported and component set up.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + data = { + CONF_AGENCY: "sf-muni", + CONF_ROUTE: "F", + CONF_STOP: "5650", + } + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=data, + ) + await hass.async_block_till_done() + + assert result.get("type") == FlowResultType.CREATE_ENTRY + assert ( + result.get("title") + == "San Francisco Muni F - Market & Wharves Market St & 7th St (Outbound)" + ) + assert result.get("data") == {CONF_NAME: "sf-muni F", **data} + + assert len(mock_setup_entry.mock_calls) == 1 + + # Check duplicate entries are aborted + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=data, + ) + await hass.async_block_till_done() + + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "already_configured" + + +@pytest.mark.parametrize( + ("override", "expected_reason"), + ( + ({CONF_AGENCY: "not muni"}, "invalid_agency"), + ({CONF_ROUTE: "not F"}, "invalid_route"), + ({CONF_STOP: "not 5650"}, "invalid_stop"), + ), +) +async def test_import_config_invalid( + hass: HomeAssistant, + mock_setup_entry: MagicMock, + mock_nextbus_lists: MagicMock, + override: dict[str, str], + expected_reason: str, +) -> None: + """Test user is redirected to user setup flow because they have invalid config.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + data = { + CONF_AGENCY: "sf-muni", + CONF_ROUTE: "F", + CONF_STOP: "5650", + **override, + } + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=data, + ) + await hass.async_block_till_done() + + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == expected_reason + + +async def test_user_config( + hass: HomeAssistant, mock_setup_entry: MagicMock, mock_nextbus_lists: MagicMock +) -> None: + """Test we get the form.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result.get("type") == FlowResultType.FORM + assert result.get("step_id") == "agency" + + # Select agency + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_AGENCY: "sf-muni", + }, + ) + await hass.async_block_till_done() + + assert result.get("type") == "form" + assert result.get("step_id") == "route" + + # Select route + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_ROUTE: "F", + }, + ) + await hass.async_block_till_done() + + assert result.get("type") == FlowResultType.FORM + assert result.get("step_id") == "stop" + + # Select stop + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_STOP: "5650", + }, + ) + await hass.async_block_till_done() + + assert result.get("type") == FlowResultType.CREATE_ENTRY + assert result.get("data") == { + "agency": "sf-muni", + "route": "F", + "stop": "5650", + } + + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/nextbus/test_sensor.py b/tests/components/nextbus/test_sensor.py index 4884d04d3aa978..071dd95fe7bae3 100644 --- a/tests/components/nextbus/test_sensor.py +++ b/tests/components/nextbus/test_sensor.py @@ -1,15 +1,24 @@ """The tests for the nexbus sensor component.""" +from collections.abc import Generator from copy import deepcopy -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest -import homeassistant.components.nextbus.sensor as nextbus -import homeassistant.components.sensor as sensor -from homeassistant.core import HomeAssistant +from homeassistant.components import sensor +from homeassistant.components.nextbus.const import ( + CONF_AGENCY, + CONF_ROUTE, + CONF_STOP, + DOMAIN, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_NAME +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 assert_setup_component +from tests.common import MockConfigEntry VALID_AGENCY = "sf-muni" VALID_ROUTE = "F" @@ -17,24 +26,34 @@ VALID_AGENCY_TITLE = "San Francisco Muni" VALID_ROUTE_TITLE = "F-Market & Wharves" VALID_STOP_TITLE = "Market St & 7th St" -SENSOR_ID_SHORT = "sensor.sf_muni_f" +SENSOR_ID = "sensor.san_francisco_muni_f_market_wharves_market_st_7th_st" + +PLATFORM_CONFIG = { + sensor.DOMAIN: { + "platform": DOMAIN, + CONF_AGENCY: VALID_AGENCY, + CONF_ROUTE: VALID_ROUTE, + CONF_STOP: VALID_STOP, + }, +} + CONFIG_BASIC = { - "sensor": { - "platform": "nextbus", - "agency": VALID_AGENCY, - "route": VALID_ROUTE, - "stop": VALID_STOP, + DOMAIN: { + CONF_AGENCY: VALID_AGENCY, + CONF_ROUTE: VALID_ROUTE, + CONF_STOP: VALID_STOP, } } -CONFIG_INVALID_MISSING = {"sensor": {"platform": "nextbus"}} - BASIC_RESULTS = { "predictions": { "agencyTitle": VALID_AGENCY_TITLE, + "agencyTag": VALID_AGENCY, "routeTitle": VALID_ROUTE_TITLE, + "routeTag": VALID_ROUTE, "stopTitle": VALID_STOP_TITLE, + "stopTag": VALID_STOP, "direction": { "title": "Outbound", "prediction": [ @@ -48,24 +67,19 @@ } -async def assert_setup_sensor(hass, config, count=1): - """Set up the sensor and assert it's been created.""" - with assert_setup_component(count): - assert await async_setup_component(hass, sensor.DOMAIN, config) - await hass.async_block_till_done() - - @pytest.fixture -def mock_nextbus(): +def mock_nextbus() -> Generator[MagicMock, None, None]: """Create a mock py_nextbus module.""" with patch( - "homeassistant.components.nextbus.sensor.NextBusClient" - ) as NextBusClient: - yield NextBusClient + "homeassistant.components.nextbus.sensor.NextBusClient", + ) as client: + yield client @pytest.fixture -def mock_nextbus_predictions(mock_nextbus): +def mock_nextbus_predictions( + mock_nextbus: MagicMock, +) -> Generator[MagicMock, None, None]: """Create a mock of NextBusClient predictions.""" instance = mock_nextbus.return_value instance.get_predictions_for_multi_stops.return_value = BASIC_RESULTS @@ -73,63 +87,69 @@ def mock_nextbus_predictions(mock_nextbus): return instance.get_predictions_for_multi_stops -@pytest.fixture -def mock_nextbus_lists(mock_nextbus): - """Mock all list functions in nextbus to test validate logic.""" - instance = mock_nextbus.return_value - instance.get_agency_list.return_value = { - "agency": [{"tag": "sf-muni", "title": "San Francisco Muni"}] - } - instance.get_route_list.return_value = { - "route": [{"tag": "F", "title": "F - Market & Wharves"}] - } - instance.get_route_config.return_value = { - "route": {"stop": [{"tag": "5650", "title": "Market St & 7th St"}]} - } - +async def assert_setup_sensor( + hass: HomeAssistant, + config: dict[str, str], + expected_state=ConfigEntryState.LOADED, +) -> MockConfigEntry: + """Set up the sensor and assert it's been created.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data=config[DOMAIN], + title=f"{VALID_AGENCY_TITLE} {VALID_ROUTE_TITLE} {VALID_STOP_TITLE}", + unique_id=f"{VALID_AGENCY}_{VALID_ROUTE}_{VALID_STOP}", + ) + config_entry.add_to_hass(hass) -async def test_valid_config( - hass: HomeAssistant, mock_nextbus, mock_nextbus_lists -) -> None: - """Test that sensor is set up properly with valid config.""" - await assert_setup_sensor(hass, CONFIG_BASIC) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is expected_state -async def test_invalid_config( - hass: HomeAssistant, mock_nextbus, mock_nextbus_lists -) -> None: - """Checks that component is not setup when missing information.""" - await assert_setup_sensor(hass, CONFIG_INVALID_MISSING, count=0) + return config_entry -async def test_validate_tags( - hass: HomeAssistant, mock_nextbus, mock_nextbus_lists +async def test_legacy_yaml_setup( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, ) -> None: - """Test that additional validation against the API is successful.""" - # with self.subTest('Valid everything'): - assert nextbus.validate_tags(mock_nextbus(), VALID_AGENCY, VALID_ROUTE, VALID_STOP) - # with self.subTest('Invalid agency'): - assert not nextbus.validate_tags( - mock_nextbus(), "not-valid", VALID_ROUTE, VALID_STOP + """Test config setup and yaml deprecation.""" + with patch( + "homeassistant.components.nextbus.config_flow.NextBusClient", + ) as NextBusClient: + NextBusClient.return_value.get_predictions_for_multi_stops.return_value = ( + BASIC_RESULTS + ) + await async_setup_component(hass, sensor.DOMAIN, PLATFORM_CONFIG) + await hass.async_block_till_done() + + issue = issue_registry.async_get_issue( + HOMEASSISTANT_DOMAIN, f"deprecated_yaml_{DOMAIN}" ) + assert issue - # with self.subTest('Invalid route'): - assert not nextbus.validate_tags(mock_nextbus(), VALID_AGENCY, "0", VALID_STOP) - # with self.subTest('Invalid stop'): - assert not nextbus.validate_tags(mock_nextbus(), VALID_AGENCY, VALID_ROUTE, 0) +async def test_valid_config( + hass: HomeAssistant, mock_nextbus: MagicMock, mock_nextbus_lists: MagicMock +) -> None: + """Test that sensor is set up properly with valid config.""" + await assert_setup_sensor(hass, CONFIG_BASIC) async def test_verify_valid_state( - hass: HomeAssistant, mock_nextbus, mock_nextbus_lists, mock_nextbus_predictions + hass: HomeAssistant, + mock_nextbus: MagicMock, + mock_nextbus_lists: MagicMock, + mock_nextbus_predictions: MagicMock, ) -> None: """Verify all attributes are set from a valid response.""" await assert_setup_sensor(hass, CONFIG_BASIC) + mock_nextbus_predictions.assert_called_once_with( [{"stop_tag": VALID_STOP, "route_tag": VALID_ROUTE}], VALID_AGENCY ) - state = hass.states.get(SENSOR_ID_SHORT) + 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_TITLE @@ -140,14 +160,20 @@ async def test_verify_valid_state( async def test_message_dict( - hass: HomeAssistant, mock_nextbus, mock_nextbus_lists, mock_nextbus_predictions + hass: HomeAssistant, + mock_nextbus: MagicMock, + mock_nextbus_lists: MagicMock, + mock_nextbus_predictions: MagicMock, ) -> None: """Verify that a single dict message is rendered correctly.""" mock_nextbus_predictions.return_value = { "predictions": { "agencyTitle": VALID_AGENCY_TITLE, + "agencyTag": VALID_AGENCY, "routeTitle": VALID_ROUTE_TITLE, + "routeTag": VALID_ROUTE, "stopTitle": VALID_STOP_TITLE, + "stopTag": VALID_STOP, "message": {"text": "Message"}, "direction": { "title": "Outbound", @@ -162,20 +188,26 @@ async def test_message_dict( await assert_setup_sensor(hass, CONFIG_BASIC) - state = hass.states.get(SENSOR_ID_SHORT) + state = hass.states.get(SENSOR_ID) assert state is not None assert state.attributes["message"] == "Message" async def test_message_list( - hass: HomeAssistant, mock_nextbus, mock_nextbus_lists, mock_nextbus_predictions + hass: HomeAssistant, + mock_nextbus: MagicMock, + mock_nextbus_lists: MagicMock, + mock_nextbus_predictions: MagicMock, ) -> None: """Verify that a list of messages are rendered correctly.""" mock_nextbus_predictions.return_value = { "predictions": { "agencyTitle": VALID_AGENCY_TITLE, + "agencyTag": VALID_AGENCY, "routeTitle": VALID_ROUTE_TITLE, + "routeTag": VALID_ROUTE, "stopTitle": VALID_STOP_TITLE, + "stopTag": VALID_STOP, "message": [{"text": "Message 1"}, {"text": "Message 2"}], "direction": { "title": "Outbound", @@ -190,20 +222,26 @@ async def test_message_list( await assert_setup_sensor(hass, CONFIG_BASIC) - state = hass.states.get(SENSOR_ID_SHORT) + state = hass.states.get(SENSOR_ID) assert state is not None assert state.attributes["message"] == "Message 1 -- Message 2" async def test_direction_list( - hass: HomeAssistant, mock_nextbus, mock_nextbus_lists, mock_nextbus_predictions + hass: HomeAssistant, + mock_nextbus: MagicMock, + mock_nextbus_lists: MagicMock, + mock_nextbus_predictions: MagicMock, ) -> None: """Verify that a list of messages are rendered correctly.""" mock_nextbus_predictions.return_value = { "predictions": { "agencyTitle": VALID_AGENCY_TITLE, + "agencyTag": VALID_AGENCY, "routeTitle": VALID_ROUTE_TITLE, + "routeTag": VALID_ROUTE, "stopTitle": VALID_STOP_TITLE, + "stopTag": VALID_STOP, "message": [{"text": "Message 1"}, {"text": "Message 2"}], "direction": [ { @@ -224,7 +262,7 @@ async def test_direction_list( await assert_setup_sensor(hass, CONFIG_BASIC) - state = hass.states.get(SENSOR_ID_SHORT) + 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_TITLE @@ -235,46 +273,67 @@ async def test_direction_list( async def test_custom_name( - hass: HomeAssistant, mock_nextbus, mock_nextbus_lists, mock_nextbus_predictions + hass: HomeAssistant, + mock_nextbus: MagicMock, + mock_nextbus_lists: MagicMock, + mock_nextbus_predictions: MagicMock, ) -> None: """Verify that a custom name can be set via config.""" config = deepcopy(CONFIG_BASIC) - config["sensor"]["name"] = "Custom Name" + config[DOMAIN][CONF_NAME] = "Custom Name" await assert_setup_sensor(hass, config) state = hass.states.get("sensor.custom_name") assert state is not None + assert state.name == "Custom Name" +@pytest.mark.parametrize( + "prediction_results", + ( + {}, + {"Error": "Failed"}, + ), +) async def test_no_predictions( - hass: HomeAssistant, mock_nextbus, mock_nextbus_predictions, mock_nextbus_lists + hass: HomeAssistant, + mock_nextbus: MagicMock, + mock_nextbus_predictions: MagicMock, + mock_nextbus_lists: MagicMock, + prediction_results: dict[str, str], ) -> None: """Verify there are no exceptions when no predictions are returned.""" - mock_nextbus_predictions.return_value = {} + mock_nextbus_predictions.return_value = prediction_results await assert_setup_sensor(hass, CONFIG_BASIC) - state = hass.states.get(SENSOR_ID_SHORT) + state = hass.states.get(SENSOR_ID) assert state is not None assert state.state == "unknown" async def test_verify_no_upcoming( - hass: HomeAssistant, mock_nextbus, mock_nextbus_lists, mock_nextbus_predictions + hass: HomeAssistant, + mock_nextbus: MagicMock, + mock_nextbus_lists: MagicMock, + mock_nextbus_predictions: MagicMock, ) -> None: """Verify attributes are set despite no upcoming times.""" mock_nextbus_predictions.return_value = { "predictions": { "agencyTitle": VALID_AGENCY_TITLE, + "agencyTag": VALID_AGENCY, "routeTitle": VALID_ROUTE_TITLE, + "routeTag": VALID_ROUTE, "stopTitle": VALID_STOP_TITLE, + "stopTag": VALID_STOP, "direction": {"title": "Outbound", "prediction": []}, } } await assert_setup_sensor(hass, CONFIG_BASIC) - state = hass.states.get(SENSOR_ID_SHORT) + state = hass.states.get(SENSOR_ID) assert state is not None assert state.state == "unknown" assert state.attributes["upcoming"] == "No upcoming predictions" diff --git a/tests/components/nextbus/test_util.py b/tests/components/nextbus/test_util.py new file mode 100644 index 00000000000000..798171464e657d --- /dev/null +++ b/tests/components/nextbus/test_util.py @@ -0,0 +1,34 @@ +"""Test NextBus util functions.""" +from typing import Any + +import pytest + +from homeassistant.components.nextbus.util import listify, maybe_first + + +@pytest.mark.parametrize( + ("input", "expected"), + ( + ("foo", ["foo"]), + (["foo"], ["foo"]), + (None, []), + ), +) +def test_listify(input: Any, expected: list[Any]) -> None: + """Test input listification.""" + assert listify(input) == expected + + +@pytest.mark.parametrize( + ("input", "expected"), + ( + ([], []), + (None, None), + ("test", "test"), + (["test"], "test"), + (["test", "second"], "test"), + ), +) +def test_maybe_first(input: list[Any] | None, expected: Any) -> None: + """Test maybe getting the first thing from a list.""" + assert maybe_first(input) == expected diff --git a/tests/components/nut/test_config_flow.py b/tests/components/nut/test_config_flow.py index 8ce4916fc66865..46bc2bc2a64336 100644 --- a/tests/components/nut/test_config_flow.py +++ b/tests/components/nut/test_config_flow.py @@ -1,4 +1,5 @@ """Test the Network UPS Tools (NUT) config flow.""" +from ipaddress import ip_address from unittest.mock import patch from pynut2.nut2 import PyNUTError @@ -36,8 +37,8 @@ async def test_form_zeroconf(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.5", - addresses=["192.168.1.5"], + ip_address=ip_address("192.168.1.5"), + ip_addresses=[ip_address("192.168.1.5")], hostname="mock_hostname", name="mock_name", port=1234, diff --git a/tests/components/nzbget/test_config_flow.py b/tests/components/nzbget/test_config_flow.py index c078a6523bc1ab..e26be8b9880cfd 100644 --- a/tests/components/nzbget/test_config_flow.py +++ b/tests/components/nzbget/test_config_flow.py @@ -5,7 +5,7 @@ from homeassistant.components.nzbget.const import DOMAIN from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import CONF_SCAN_INTERVAL, CONF_VERIFY_SSL +from homeassistant.const import CONF_VERIFY_SSL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -122,33 +122,3 @@ async def test_user_form_single_instance_allowed(hass: HomeAssistant) -> None: ) assert result["type"] == FlowResultType.ABORT assert result["reason"] == "single_instance_allowed" - - -async def test_options_flow(hass: HomeAssistant, nzbget_api) -> None: - """Test updating options.""" - entry = MockConfigEntry( - domain=DOMAIN, - data=ENTRY_CONFIG, - options={CONF_SCAN_INTERVAL: 5}, - ) - entry.add_to_hass(hass) - - with patch("homeassistant.components.nzbget.PLATFORMS", []): - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - assert entry.options[CONF_SCAN_INTERVAL] == 5 - - result = await hass.config_entries.options.async_init(entry.entry_id) - assert result["type"] == FlowResultType.FORM - assert result["step_id"] == "init" - - with _patch_async_setup_entry(): - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={CONF_SCAN_INTERVAL: 15}, - ) - await hass.async_block_till_done() - - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["data"][CONF_SCAN_INTERVAL] == 15 diff --git a/tests/components/octoprint/test_config_flow.py b/tests/components/octoprint/test_config_flow.py index f2423f6da27bb7..e3cf45708fa97b 100644 --- a/tests/components/octoprint/test_config_flow.py +++ b/tests/components/octoprint/test_config_flow.py @@ -1,4 +1,5 @@ """Test the OctoPrint config flow.""" +from ipaddress import ip_address from unittest.mock import patch from pyoctoprintapi import ApiError, DiscoverySettings @@ -174,8 +175,8 @@ async def test_show_zerconf_form(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.123", - addresses=["192.168.1.123"], + ip_address=ip_address("192.168.1.123"), + ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", name="mock_name", port=80, @@ -496,8 +497,8 @@ async def test_duplicate_zerconf_ignored(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.123", - addresses=["192.168.1.123"], + ip_address=ip_address("192.168.1.123"), + ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", name="mock_name", port=80, diff --git a/tests/components/overkiz/test_config_flow.py b/tests/components/overkiz/test_config_flow.py index 89b0b7e84273b5..a9d950a3a6626a 100644 --- a/tests/components/overkiz/test_config_flow.py +++ b/tests/components/overkiz/test_config_flow.py @@ -1,6 +1,7 @@ """Tests for Overkiz (by Somfy) config flow.""" from __future__ import annotations +from ipaddress import ip_address from unittest.mock import AsyncMock, Mock, patch from aiohttp import ClientError @@ -37,8 +38,8 @@ MOCK_GATEWAY2_RESPONSE = [Mock(id=TEST_GATEWAY_ID2)] FAKE_ZERO_CONF_INFO = ZeroconfServiceInfo( - host="192.168.0.51", - addresses=["192.168.0.51"], + ip_address=ip_address("192.168.0.51"), + ip_addresses=[ip_address("192.168.0.51")], port=443, hostname=f"gateway-{TEST_GATEWAY_ID}.local.", type="_kizbox._tcp.local.", diff --git a/tests/components/plugwise/fixtures/adam_jip/all_data.json b/tests/components/plugwise/fixtures/adam_jip/all_data.json index 177478f0fff15b..4dda9af3b54df2 100644 --- a/tests/components/plugwise/fixtures/adam_jip/all_data.json +++ b/tests/components/plugwise/fixtures/adam_jip/all_data.json @@ -20,6 +20,12 @@ "setpoint": 13.0, "temperature": 24.2 }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, "thermostat": { "lower_bound": 0.0, "resolution": 0.01, @@ -43,6 +49,12 @@ "temperature_difference": 2.0, "valve_position": 0.0 }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.1, + "upper_bound": 2.0 + }, "vendor": "Plugwise", "zigbee_mac_address": "ABCD012345670A07" }, @@ -60,6 +72,12 @@ "temperature_difference": 1.7, "valve_position": 0.0 }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.1, + "upper_bound": 2.0 + }, "vendor": "Plugwise", "zigbee_mac_address": "ABCD012345670A05" }, @@ -99,6 +117,12 @@ "setpoint": 13.0, "temperature": 30.0 }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, "thermostat": { "lower_bound": 0.0, "resolution": 0.01, @@ -122,6 +146,12 @@ "temperature_difference": 1.8, "valve_position": 100 }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.1, + "upper_bound": 2.0 + }, "vendor": "Plugwise", "zigbee_mac_address": "ABCD012345670A09" }, @@ -145,6 +175,12 @@ "setpoint": 13.0, "temperature": 30.0 }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, "thermostat": { "lower_bound": 0.0, "resolution": 0.01, @@ -187,6 +223,12 @@ "temperature_difference": 1.9, "valve_position": 0.0 }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.1, + "upper_bound": 2.0 + }, "vendor": "Plugwise", "zigbee_mac_address": "ABCD012345670A04" }, @@ -246,6 +288,12 @@ "setpoint": 9.0, "temperature": 27.4 }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, "thermostat": { "lower_bound": 4.0, "resolution": 0.01, diff --git a/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/all_data.json b/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/all_data.json index 63f0012ea9211b..0cc28731ff4a8a 100644 --- a/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/all_data.json +++ b/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/all_data.json @@ -95,6 +95,12 @@ "temperature_difference": -0.4, "valve_position": 0.0 }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, "vendor": "Plugwise", "zigbee_mac_address": "ABCD012345670A17" }, @@ -123,6 +129,12 @@ "setpoint": 15.0, "temperature": 17.2 }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, "thermostat": { "lower_bound": 0.0, "resolution": 0.01, @@ -200,6 +212,12 @@ "temperature_difference": -0.2, "valve_position": 0.0 }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, "vendor": "Plugwise", "zigbee_mac_address": "ABCD012345670A09" }, @@ -217,6 +235,12 @@ "temperature_difference": 3.5, "valve_position": 100 }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, "vendor": "Plugwise", "zigbee_mac_address": "ABCD012345670A02" }, @@ -245,6 +269,12 @@ "setpoint": 21.5, "temperature": 20.9 }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, "thermostat": { "lower_bound": 0.0, "resolution": 0.01, @@ -289,6 +319,12 @@ "temperature_difference": 0.1, "valve_position": 0.0 }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, "vendor": "Plugwise", "zigbee_mac_address": "ABCD012345670A10" }, @@ -317,6 +353,12 @@ "setpoint": 13.0, "temperature": 16.5 }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, "thermostat": { "lower_bound": 0.0, "resolution": 0.01, @@ -353,6 +395,12 @@ "temperature_difference": 0.0, "valve_position": 0.0 }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, "thermostat": { "lower_bound": 0.0, "resolution": 0.01, @@ -387,6 +435,12 @@ "setpoint": 14.0, "temperature": 18.9 }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, "thermostat": { "lower_bound": 0.0, "resolution": 0.01, diff --git a/tests/components/plugwise/fixtures/anna_heatpump_heating/all_data.json b/tests/components/plugwise/fixtures/anna_heatpump_heating/all_data.json index 49b5221233fd36..cdddfdb3439c03 100644 --- a/tests/components/plugwise/fixtures/anna_heatpump_heating/all_data.json +++ b/tests/components/plugwise/fixtures/anna_heatpump_heating/all_data.json @@ -76,6 +76,12 @@ "setpoint": 20.5, "temperature": 19.3 }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": -0.5, + "upper_bound": 2.0 + }, "thermostat": { "lower_bound": 4.0, "resolution": 0.1, diff --git a/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json b/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json index 92618a901890ed..ac7e602821e06c 100644 --- a/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json +++ b/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json @@ -40,6 +40,12 @@ "temperature_difference": 2.3, "valve_position": 0.0 }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.1, + "upper_bound": 2.0 + }, "vendor": "Plugwise", "zigbee_mac_address": "ABCD012345670A01" }, @@ -118,6 +124,12 @@ "setpoint_low": 20.0, "temperature": 239 }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, "thermostat": { "lower_bound": 0.0, "resolution": 0.01, diff --git a/tests/components/plugwise/fixtures/m_adam_heating/all_data.json b/tests/components/plugwise/fixtures/m_adam_heating/all_data.json index 4345cf76a3a8a8..a4923b1c5490f5 100644 --- a/tests/components/plugwise/fixtures/m_adam_heating/all_data.json +++ b/tests/components/plugwise/fixtures/m_adam_heating/all_data.json @@ -45,6 +45,12 @@ "temperature_difference": 2.3, "valve_position": 0.0 }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.1, + "upper_bound": 2.0 + }, "vendor": "Plugwise", "zigbee_mac_address": "ABCD012345670A01" }, @@ -114,6 +120,12 @@ "setpoint": 15.0, "temperature": 17.9 }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, "thermostat": { "lower_bound": 0.0, "resolution": 0.01, diff --git a/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/all_data.json b/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/all_data.json index 20f2db213bdb53..f98f253e9389f2 100644 --- a/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/all_data.json +++ b/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/all_data.json @@ -78,6 +78,12 @@ "setpoint_low": 20.5, "temperature": 26.3 }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": -0.5, + "upper_bound": 2.0 + }, "thermostat": { "lower_bound": 4.0, "resolution": 0.1, diff --git a/tests/components/plugwise/fixtures/m_anna_heatpump_idle/all_data.json b/tests/components/plugwise/fixtures/m_anna_heatpump_idle/all_data.json index 3a7bd2dae89655..56d26f67acb423 100644 --- a/tests/components/plugwise/fixtures/m_anna_heatpump_idle/all_data.json +++ b/tests/components/plugwise/fixtures/m_anna_heatpump_idle/all_data.json @@ -78,6 +78,12 @@ "setpoint_low": 20.5, "temperature": 23.0 }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": -0.5, + "upper_bound": 2.0 + }, "thermostat": { "lower_bound": 4.0, "resolution": 0.1, diff --git a/tests/components/plugwise/fixtures/p1v4_442_triple/all_data.json b/tests/components/plugwise/fixtures/p1v4_442_triple/all_data.json index e9a3b4c68b96e0..d503bd3a59d267 100644 --- a/tests/components/plugwise/fixtures/p1v4_442_triple/all_data.json +++ b/tests/components/plugwise/fixtures/p1v4_442_triple/all_data.json @@ -2,7 +2,7 @@ "devices": { "03e65b16e4b247a29ae0d75a78cb492e": { "binary_sensors": { - "plugwise_notification": false + "plugwise_notification": true }, "dev_class": "gateway", "firmware": "4.4.2", @@ -51,7 +51,11 @@ }, "gateway": { "gateway_id": "03e65b16e4b247a29ae0d75a78cb492e", - "notifications": {}, + "notifications": { + "97a04c0c263049b29350a660b4cdd01e": { + "warning": "The Smile P1 is not connected to a smart meter." + } + }, "smile_name": "Smile P1" } } diff --git a/tests/components/plugwise/fixtures/p1v4_442_triple/notifications.json b/tests/components/plugwise/fixtures/p1v4_442_triple/notifications.json index 0967ef424bce67..49db062035aa38 100644 --- a/tests/components/plugwise/fixtures/p1v4_442_triple/notifications.json +++ b/tests/components/plugwise/fixtures/p1v4_442_triple/notifications.json @@ -1 +1,5 @@ -{} +{ + "97a04c0c263049b29350a660b4cdd01e": { + "warning": "The Smile P1 is not connected to a smart meter." + } +} diff --git a/tests/components/plugwise/fixtures/stretch_v31/all_data.json b/tests/components/plugwise/fixtures/stretch_v31/all_data.json index c336a9cb9c2df2..8604aaae10e6cc 100644 --- a/tests/components/plugwise/fixtures/stretch_v31/all_data.json +++ b/tests/components/plugwise/fixtures/stretch_v31/all_data.json @@ -48,15 +48,6 @@ "vendor": "Plugwise", "zigbee_mac_address": "ABCD012345670A07" }, - "71e1944f2a944b26ad73323e399efef0": { - "dev_class": "switching", - "members": ["5ca521ac179d468e91d772eeeb8a2117"], - "model": "Switchgroup", - "name": "Test", - "switches": { - "relay": true - } - }, "aac7b735042c4832ac9ff33aae4f453b": { "dev_class": "dishwasher", "firmware": "2011-06-27T10:52:18+02:00", diff --git a/tests/components/plugwise/snapshots/test_diagnostics.ambr b/tests/components/plugwise/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000000..da6e896442164b --- /dev/null +++ b/tests/components/plugwise/snapshots/test_diagnostics.ambr @@ -0,0 +1,516 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'devices': dict({ + '02cf28bfec924855854c544690a609ef': dict({ + 'available': True, + 'dev_class': 'vcr', + 'firmware': '2019-06-21T02:00:00+02:00', + 'location': 'cd143c07248f491493cea0533bc3d669', + 'model': 'Plug', + 'name': 'NVR', + 'sensors': dict({ + 'electricity_consumed': 34.0, + 'electricity_consumed_interval': 9.15, + 'electricity_produced': 0.0, + 'electricity_produced_interval': 0.0, + }), + 'switches': dict({ + 'lock': True, + 'relay': True, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A15', + }), + '21f2b542c49845e6bb416884c55778d6': dict({ + 'available': True, + 'dev_class': 'game_console', + 'firmware': '2019-06-21T02:00:00+02:00', + 'location': 'cd143c07248f491493cea0533bc3d669', + 'model': 'Plug', + 'name': 'Playstation Smart Plug', + 'sensors': dict({ + 'electricity_consumed': 82.6, + 'electricity_consumed_interval': 8.6, + 'electricity_produced': 0.0, + 'electricity_produced_interval': 0.0, + }), + 'switches': dict({ + 'lock': False, + 'relay': True, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A12', + }), + '4a810418d5394b3f82727340b91ba740': dict({ + 'available': True, + 'dev_class': 'router', + 'firmware': '2019-06-21T02:00:00+02:00', + 'location': 'cd143c07248f491493cea0533bc3d669', + 'model': 'Plug', + 'name': 'USG Smart Plug', + 'sensors': dict({ + 'electricity_consumed': 8.5, + 'electricity_consumed_interval': 0.0, + 'electricity_produced': 0.0, + 'electricity_produced_interval': 0.0, + }), + 'switches': dict({ + 'lock': True, + 'relay': True, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A16', + }), + '675416a629f343c495449970e2ca37b5': dict({ + 'available': True, + 'dev_class': 'router', + 'firmware': '2019-06-21T02:00:00+02:00', + 'location': 'cd143c07248f491493cea0533bc3d669', + 'model': 'Plug', + 'name': 'Ziggo Modem', + 'sensors': dict({ + 'electricity_consumed': 12.2, + 'electricity_consumed_interval': 2.97, + 'electricity_produced': 0.0, + 'electricity_produced_interval': 0.0, + }), + 'switches': dict({ + 'lock': True, + 'relay': True, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A01', + }), + '680423ff840043738f42cc7f1ff97a36': dict({ + 'available': True, + 'dev_class': 'thermo_sensor', + 'firmware': '2019-03-27T01:00:00+01:00', + 'hardware': '1', + 'location': '08963fec7c53423ca5680aa4cb502c63', + 'model': 'Tom/Floor', + 'name': 'Thermostatic Radiator Badkamer', + 'sensors': dict({ + 'battery': 51, + 'setpoint': 14.0, + 'temperature': 19.1, + 'temperature_difference': -0.4, + 'valve_position': 0.0, + }), + 'temperature_offset': dict({ + 'lower_bound': -2.0, + 'resolution': 0.1, + 'setpoint': 0.0, + 'upper_bound': 2.0, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A17', + }), + '6a3bf693d05e48e0b460c815a4fdd09d': dict({ + 'active_preset': 'asleep', + 'available': True, + 'available_schedules': list([ + 'CV Roan', + 'Bios Schema met Film Avond', + 'GF7 Woonkamer', + 'Badkamer Schema', + 'CV Jessie', + ]), + 'dev_class': 'zone_thermostat', + 'firmware': '2016-10-27T02:00:00+02:00', + 'hardware': '255', + 'last_used': 'CV Jessie', + 'location': '82fa13f017d240daa0d0ea1775420f24', + 'mode': 'auto', + 'model': 'Lisa', + 'name': 'Zone Thermostat Jessie', + 'preset_modes': list([ + 'home', + 'asleep', + 'away', + 'vacation', + 'no_frost', + ]), + 'select_schedule': 'CV Jessie', + 'sensors': dict({ + 'battery': 37, + 'setpoint': 15.0, + 'temperature': 17.2, + }), + 'temperature_offset': dict({ + 'lower_bound': -2.0, + 'resolution': 0.1, + 'setpoint': 0.0, + 'upper_bound': 2.0, + }), + 'thermostat': dict({ + 'lower_bound': 0.0, + 'resolution': 0.01, + 'setpoint': 15.0, + 'upper_bound': 99.9, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A03', + }), + '78d1126fc4c743db81b61c20e88342a7': dict({ + 'available': True, + 'dev_class': 'central_heating_pump', + 'firmware': '2019-06-21T02:00:00+02:00', + 'location': 'c50f167537524366a5af7aa3942feb1e', + 'model': 'Plug', + 'name': 'CV Pomp', + 'sensors': dict({ + 'electricity_consumed': 35.6, + 'electricity_consumed_interval': 7.37, + 'electricity_produced': 0.0, + 'electricity_produced_interval': 0.0, + }), + 'switches': dict({ + 'relay': True, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A05', + }), + '90986d591dcd426cae3ec3e8111ff730': dict({ + 'binary_sensors': dict({ + 'heating_state': True, + }), + 'dev_class': 'heater_central', + 'location': '1f9dcf83fd4e4b66b72ff787957bfe5d', + 'model': 'Unknown', + 'name': 'OnOff', + 'sensors': dict({ + 'intended_boiler_temperature': 70.0, + 'modulation_level': 1, + 'water_temperature': 70.0, + }), + }), + 'a28f588dc4a049a483fd03a30361ad3a': dict({ + 'available': True, + 'dev_class': 'settop', + 'firmware': '2019-06-21T02:00:00+02:00', + 'location': 'cd143c07248f491493cea0533bc3d669', + 'model': 'Plug', + 'name': 'Fibaro HC2', + 'sensors': dict({ + 'electricity_consumed': 12.5, + 'electricity_consumed_interval': 3.8, + 'electricity_produced': 0.0, + 'electricity_produced_interval': 0.0, + }), + 'switches': dict({ + 'lock': True, + 'relay': True, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A13', + }), + 'a2c3583e0a6349358998b760cea82d2a': dict({ + 'available': True, + 'dev_class': 'thermo_sensor', + 'firmware': '2019-03-27T01:00:00+01:00', + 'hardware': '1', + 'location': '12493538af164a409c6a1c79e38afe1c', + 'model': 'Tom/Floor', + 'name': 'Bios Cv Thermostatic Radiator ', + 'sensors': dict({ + 'battery': 62, + 'setpoint': 13.0, + 'temperature': 17.2, + 'temperature_difference': -0.2, + 'valve_position': 0.0, + }), + 'temperature_offset': dict({ + 'lower_bound': -2.0, + 'resolution': 0.1, + 'setpoint': 0.0, + 'upper_bound': 2.0, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A09', + }), + 'b310b72a0e354bfab43089919b9a88bf': dict({ + 'available': True, + 'dev_class': 'thermo_sensor', + 'firmware': '2019-03-27T01:00:00+01:00', + 'hardware': '1', + 'location': 'c50f167537524366a5af7aa3942feb1e', + 'model': 'Tom/Floor', + 'name': 'Floor kraan', + 'sensors': dict({ + 'setpoint': 21.5, + 'temperature': 26.0, + 'temperature_difference': 3.5, + 'valve_position': 100, + }), + 'temperature_offset': dict({ + 'lower_bound': -2.0, + 'resolution': 0.1, + 'setpoint': 0.0, + 'upper_bound': 2.0, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A02', + }), + 'b59bcebaf94b499ea7d46e4a66fb62d8': dict({ + 'active_preset': 'home', + 'available': True, + 'available_schedules': list([ + 'CV Roan', + 'Bios Schema met Film Avond', + 'GF7 Woonkamer', + 'Badkamer Schema', + 'CV Jessie', + ]), + 'dev_class': 'zone_thermostat', + 'firmware': '2016-08-02T02:00:00+02:00', + 'hardware': '255', + 'last_used': 'GF7 Woonkamer', + 'location': 'c50f167537524366a5af7aa3942feb1e', + 'mode': 'auto', + 'model': 'Lisa', + 'name': 'Zone Lisa WK', + 'preset_modes': list([ + 'home', + 'asleep', + 'away', + 'vacation', + 'no_frost', + ]), + 'select_schedule': 'GF7 Woonkamer', + 'sensors': dict({ + 'battery': 34, + 'setpoint': 21.5, + 'temperature': 20.9, + }), + 'temperature_offset': dict({ + 'lower_bound': -2.0, + 'resolution': 0.1, + 'setpoint': 0.0, + 'upper_bound': 2.0, + }), + 'thermostat': dict({ + 'lower_bound': 0.0, + 'resolution': 0.01, + 'setpoint': 21.5, + 'upper_bound': 99.9, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A07', + }), + 'cd0ddb54ef694e11ac18ed1cbce5dbbd': dict({ + 'available': True, + 'dev_class': 'vcr', + 'firmware': '2019-06-21T02:00:00+02:00', + 'location': 'cd143c07248f491493cea0533bc3d669', + 'model': 'Plug', + 'name': 'NAS', + 'sensors': dict({ + 'electricity_consumed': 16.5, + 'electricity_consumed_interval': 0.5, + 'electricity_produced': 0.0, + 'electricity_produced_interval': 0.0, + }), + 'switches': dict({ + 'lock': True, + 'relay': True, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A14', + }), + 'd3da73bde12a47d5a6b8f9dad971f2ec': dict({ + 'available': True, + 'dev_class': 'thermo_sensor', + 'firmware': '2019-03-27T01:00:00+01:00', + 'hardware': '1', + 'location': '82fa13f017d240daa0d0ea1775420f24', + 'model': 'Tom/Floor', + 'name': 'Thermostatic Radiator Jessie', + 'sensors': dict({ + 'battery': 62, + 'setpoint': 15.0, + 'temperature': 17.1, + 'temperature_difference': 0.1, + 'valve_position': 0.0, + }), + 'temperature_offset': dict({ + 'lower_bound': -2.0, + 'resolution': 0.1, + 'setpoint': 0.0, + 'upper_bound': 2.0, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A10', + }), + 'df4a4a8169904cdb9c03d61a21f42140': dict({ + 'active_preset': 'away', + 'available': True, + 'available_schedules': list([ + 'CV Roan', + 'Bios Schema met Film Avond', + 'GF7 Woonkamer', + 'Badkamer Schema', + 'CV Jessie', + ]), + 'dev_class': 'zone_thermostat', + 'firmware': '2016-10-27T02:00:00+02:00', + 'hardware': '255', + 'last_used': 'Badkamer Schema', + 'location': '12493538af164a409c6a1c79e38afe1c', + 'mode': 'heat', + 'model': 'Lisa', + 'name': 'Zone Lisa Bios', + 'preset_modes': list([ + 'home', + 'asleep', + 'away', + 'vacation', + 'no_frost', + ]), + 'select_schedule': 'None', + 'sensors': dict({ + 'battery': 67, + 'setpoint': 13.0, + 'temperature': 16.5, + }), + 'temperature_offset': dict({ + 'lower_bound': -2.0, + 'resolution': 0.1, + 'setpoint': 0.0, + 'upper_bound': 2.0, + }), + 'thermostat': dict({ + 'lower_bound': 0.0, + 'resolution': 0.01, + 'setpoint': 13.0, + 'upper_bound': 99.9, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A06', + }), + 'e7693eb9582644e5b865dba8d4447cf1': dict({ + 'active_preset': 'no_frost', + 'available': True, + 'available_schedules': list([ + 'CV Roan', + 'Bios Schema met Film Avond', + 'GF7 Woonkamer', + 'Badkamer Schema', + 'CV Jessie', + ]), + 'dev_class': 'thermostatic_radiator_valve', + 'firmware': '2019-03-27T01:00:00+01:00', + 'hardware': '1', + 'last_used': 'Badkamer Schema', + 'location': '446ac08dd04d4eff8ac57489757b7314', + 'mode': 'heat', + 'model': 'Tom/Floor', + 'name': 'CV Kraan Garage', + 'preset_modes': list([ + 'home', + 'asleep', + 'away', + 'vacation', + 'no_frost', + ]), + 'select_schedule': 'None', + 'sensors': dict({ + 'battery': 68, + 'setpoint': 5.5, + 'temperature': 15.6, + 'temperature_difference': 0.0, + 'valve_position': 0.0, + }), + 'temperature_offset': dict({ + 'lower_bound': -2.0, + 'resolution': 0.1, + 'setpoint': 0.0, + 'upper_bound': 2.0, + }), + 'thermostat': dict({ + 'lower_bound': 0.0, + 'resolution': 0.01, + 'setpoint': 5.5, + 'upper_bound': 100.0, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A11', + }), + 'f1fee6043d3642a9b0a65297455f008e': dict({ + 'active_preset': 'away', + 'available': True, + 'available_schedules': list([ + 'CV Roan', + 'Bios Schema met Film Avond', + 'GF7 Woonkamer', + 'Badkamer Schema', + 'CV Jessie', + ]), + 'dev_class': 'zone_thermostat', + 'firmware': '2016-10-27T02:00:00+02:00', + 'hardware': '255', + 'last_used': 'Badkamer Schema', + 'location': '08963fec7c53423ca5680aa4cb502c63', + 'mode': 'auto', + 'model': 'Lisa', + 'name': 'Zone Thermostat Badkamer', + 'preset_modes': list([ + 'home', + 'asleep', + 'away', + 'vacation', + 'no_frost', + ]), + 'select_schedule': 'Badkamer Schema', + 'sensors': dict({ + 'battery': 92, + 'setpoint': 14.0, + 'temperature': 18.9, + }), + 'temperature_offset': dict({ + 'lower_bound': -2.0, + 'resolution': 0.1, + 'setpoint': 0.0, + 'upper_bound': 2.0, + }), + 'thermostat': dict({ + 'lower_bound': 0.0, + 'resolution': 0.01, + 'setpoint': 14.0, + 'upper_bound': 99.9, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A08', + }), + 'fe799307f1624099878210aa0b9f1475': dict({ + 'binary_sensors': dict({ + 'plugwise_notification': True, + }), + 'dev_class': 'gateway', + 'firmware': '3.0.15', + 'hardware': 'AME Smile 2.0 board', + 'location': '1f9dcf83fd4e4b66b72ff787957bfe5d', + 'mac_address': '012345670001', + 'model': 'Gateway', + 'name': 'Adam', + 'select_regulation_mode': 'heating', + 'sensors': dict({ + 'outdoor_temperature': 7.81, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670101', + }), + }), + 'gateway': dict({ + 'cooling_present': False, + 'gateway_id': 'fe799307f1624099878210aa0b9f1475', + 'heater_id': '90986d591dcd426cae3ec3e8111ff730', + 'notifications': dict({ + 'af82e4ccf9c548528166d38e560662a4': dict({ + 'warning': "Node Plug (with MAC address 000D6F000D13CB01, in room 'n.a.') has been unreachable since 23:03 2020-01-18. Please check the connection and restart the device.", + }), + }), + 'smile_name': 'Adam', + }), + }) +# --- diff --git a/tests/components/plugwise/test_config_flow.py b/tests/components/plugwise/test_config_flow.py index 6ca1e14a4cadea..438ab1b0870fcb 100644 --- a/tests/components/plugwise/test_config_flow.py +++ b/tests/components/plugwise/test_config_flow.py @@ -1,4 +1,5 @@ """Test the Plugwise config flow.""" +from ipaddress import ip_address from unittest.mock import AsyncMock, MagicMock, patch from plugwise.exceptions import ( @@ -36,8 +37,8 @@ TEST_USERNAME2 = "stretch" TEST_DISCOVERY = ZeroconfServiceInfo( - host=TEST_HOST, - addresses=[TEST_HOST], + ip_address=ip_address(TEST_HOST), + ip_addresses=[ip_address(TEST_HOST)], # The added `-2` is to simulate mDNS collision hostname=f"{TEST_HOSTNAME}-2.local.", name="mock_name", @@ -51,8 +52,8 @@ ) TEST_DISCOVERY2 = ZeroconfServiceInfo( - host=TEST_HOST, - addresses=[TEST_HOST], + ip_address=ip_address(TEST_HOST), + ip_addresses=[ip_address(TEST_HOST)], hostname=f"{TEST_HOSTNAME2}.local.", name="mock_name", port=DEFAULT_PORT, @@ -65,8 +66,8 @@ ) TEST_DISCOVERY_ANNA = ZeroconfServiceInfo( - host=TEST_HOST, - addresses=[TEST_HOST], + ip_address=ip_address(TEST_HOST), + ip_addresses=[ip_address(TEST_HOST)], hostname=f"{TEST_HOSTNAME}.local.", name="mock_name", port=DEFAULT_PORT, @@ -79,8 +80,8 @@ ) TEST_DISCOVERY_ADAM = ZeroconfServiceInfo( - host=TEST_HOST, - addresses=[TEST_HOST], + ip_address=ip_address(TEST_HOST), + ip_addresses=[ip_address(TEST_HOST)], hostname=f"{TEST_HOSTNAME2}.local.", name="mock_name", port=DEFAULT_PORT, diff --git a/tests/components/plugwise/test_diagnostics.py b/tests/components/plugwise/test_diagnostics.py index 5dde8a0e09ea3a..045b8641f695c2 100644 --- a/tests/components/plugwise/test_diagnostics.py +++ b/tests/components/plugwise/test_diagnostics.py @@ -1,6 +1,8 @@ """Tests for the diagnostics data provided by the Plugwise integration.""" from unittest.mock import MagicMock +from syrupy import SnapshotAssertion + from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -13,395 +15,11 @@ async def test_diagnostics( hass_client: ClientSessionGenerator, mock_smile_adam: MagicMock, init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, ) -> None: """Test diagnostics.""" - assert await get_diagnostics_for_config_entry( - hass, hass_client, init_integration - ) == { - "gateway": { - "smile_name": "Adam", - "gateway_id": "fe799307f1624099878210aa0b9f1475", - "heater_id": "90986d591dcd426cae3ec3e8111ff730", - "cooling_present": False, - "notifications": { - "af82e4ccf9c548528166d38e560662a4": { - "warning": "Node Plug (with MAC address 000D6F000D13CB01, in room 'n.a.') has been unreachable since 23:03 2020-01-18. Please check the connection and restart the device." - } - }, - }, - "devices": { - "df4a4a8169904cdb9c03d61a21f42140": { - "dev_class": "zone_thermostat", - "firmware": "2016-10-27T02:00:00+02:00", - "hardware": "255", - "location": "12493538af164a409c6a1c79e38afe1c", - "model": "Lisa", - "name": "Zone Lisa Bios", - "zigbee_mac_address": "ABCD012345670A06", - "vendor": "Plugwise", - "thermostat": { - "setpoint": 13.0, - "lower_bound": 0.0, - "upper_bound": 99.9, - "resolution": 0.01, - }, - "available": True, - "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], - "active_preset": "away", - "available_schedules": [ - "CV Roan", - "Bios Schema met Film Avond", - "GF7 Woonkamer", - "Badkamer Schema", - "CV Jessie", - ], - "select_schedule": "None", - "last_used": "Badkamer Schema", - "mode": "heat", - "sensors": {"temperature": 16.5, "setpoint": 13.0, "battery": 67}, - }, - "b310b72a0e354bfab43089919b9a88bf": { - "dev_class": "thermo_sensor", - "firmware": "2019-03-27T01:00:00+01:00", - "hardware": "1", - "location": "c50f167537524366a5af7aa3942feb1e", - "model": "Tom/Floor", - "name": "Floor kraan", - "zigbee_mac_address": "ABCD012345670A02", - "vendor": "Plugwise", - "available": True, - "sensors": { - "temperature": 26.0, - "setpoint": 21.5, - "temperature_difference": 3.5, - "valve_position": 100, - }, - }, - "a2c3583e0a6349358998b760cea82d2a": { - "dev_class": "thermo_sensor", - "firmware": "2019-03-27T01:00:00+01:00", - "hardware": "1", - "location": "12493538af164a409c6a1c79e38afe1c", - "model": "Tom/Floor", - "name": "Bios Cv Thermostatic Radiator ", - "zigbee_mac_address": "ABCD012345670A09", - "vendor": "Plugwise", - "available": True, - "sensors": { - "temperature": 17.2, - "setpoint": 13.0, - "battery": 62, - "temperature_difference": -0.2, - "valve_position": 0.0, - }, - }, - "b59bcebaf94b499ea7d46e4a66fb62d8": { - "dev_class": "zone_thermostat", - "firmware": "2016-08-02T02:00:00+02:00", - "hardware": "255", - "location": "c50f167537524366a5af7aa3942feb1e", - "model": "Lisa", - "name": "Zone Lisa WK", - "zigbee_mac_address": "ABCD012345670A07", - "vendor": "Plugwise", - "thermostat": { - "setpoint": 21.5, - "lower_bound": 0.0, - "upper_bound": 99.9, - "resolution": 0.01, - }, - "available": True, - "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], - "active_preset": "home", - "available_schedules": [ - "CV Roan", - "Bios Schema met Film Avond", - "GF7 Woonkamer", - "Badkamer Schema", - "CV Jessie", - ], - "select_schedule": "GF7 Woonkamer", - "last_used": "GF7 Woonkamer", - "mode": "auto", - "sensors": {"temperature": 20.9, "setpoint": 21.5, "battery": 34}, - }, - "fe799307f1624099878210aa0b9f1475": { - "dev_class": "gateway", - "firmware": "3.0.15", - "hardware": "AME Smile 2.0 board", - "location": "1f9dcf83fd4e4b66b72ff787957bfe5d", - "mac_address": "012345670001", - "model": "Gateway", - "name": "Adam", - "zigbee_mac_address": "ABCD012345670101", - "vendor": "Plugwise", - "select_regulation_mode": "heating", - "binary_sensors": {"plugwise_notification": True}, - "sensors": {"outdoor_temperature": 7.81}, - }, - "d3da73bde12a47d5a6b8f9dad971f2ec": { - "dev_class": "thermo_sensor", - "firmware": "2019-03-27T01:00:00+01:00", - "hardware": "1", - "location": "82fa13f017d240daa0d0ea1775420f24", - "model": "Tom/Floor", - "name": "Thermostatic Radiator Jessie", - "zigbee_mac_address": "ABCD012345670A10", - "vendor": "Plugwise", - "available": True, - "sensors": { - "temperature": 17.1, - "setpoint": 15.0, - "battery": 62, - "temperature_difference": 0.1, - "valve_position": 0.0, - }, - }, - "21f2b542c49845e6bb416884c55778d6": { - "dev_class": "game_console", - "firmware": "2019-06-21T02:00:00+02:00", - "location": "cd143c07248f491493cea0533bc3d669", - "model": "Plug", - "name": "Playstation Smart Plug", - "zigbee_mac_address": "ABCD012345670A12", - "vendor": "Plugwise", - "available": True, - "sensors": { - "electricity_consumed": 82.6, - "electricity_consumed_interval": 8.6, - "electricity_produced": 0.0, - "electricity_produced_interval": 0.0, - }, - "switches": {"relay": True, "lock": False}, - }, - "78d1126fc4c743db81b61c20e88342a7": { - "dev_class": "central_heating_pump", - "firmware": "2019-06-21T02:00:00+02:00", - "location": "c50f167537524366a5af7aa3942feb1e", - "model": "Plug", - "name": "CV Pomp", - "zigbee_mac_address": "ABCD012345670A05", - "vendor": "Plugwise", - "available": True, - "sensors": { - "electricity_consumed": 35.6, - "electricity_consumed_interval": 7.37, - "electricity_produced": 0.0, - "electricity_produced_interval": 0.0, - }, - "switches": {"relay": True}, - }, - "90986d591dcd426cae3ec3e8111ff730": { - "dev_class": "heater_central", - "location": "1f9dcf83fd4e4b66b72ff787957bfe5d", - "model": "Unknown", - "name": "OnOff", - "binary_sensors": {"heating_state": True}, - "sensors": { - "water_temperature": 70.0, - "intended_boiler_temperature": 70.0, - "modulation_level": 1, - }, - }, - "cd0ddb54ef694e11ac18ed1cbce5dbbd": { - "dev_class": "vcr", - "firmware": "2019-06-21T02:00:00+02:00", - "location": "cd143c07248f491493cea0533bc3d669", - "model": "Plug", - "name": "NAS", - "zigbee_mac_address": "ABCD012345670A14", - "vendor": "Plugwise", - "available": True, - "sensors": { - "electricity_consumed": 16.5, - "electricity_consumed_interval": 0.5, - "electricity_produced": 0.0, - "electricity_produced_interval": 0.0, - }, - "switches": {"relay": True, "lock": True}, - }, - "4a810418d5394b3f82727340b91ba740": { - "dev_class": "router", - "firmware": "2019-06-21T02:00:00+02:00", - "location": "cd143c07248f491493cea0533bc3d669", - "model": "Plug", - "name": "USG Smart Plug", - "zigbee_mac_address": "ABCD012345670A16", - "vendor": "Plugwise", - "available": True, - "sensors": { - "electricity_consumed": 8.5, - "electricity_consumed_interval": 0.0, - "electricity_produced": 0.0, - "electricity_produced_interval": 0.0, - }, - "switches": {"relay": True, "lock": True}, - }, - "02cf28bfec924855854c544690a609ef": { - "dev_class": "vcr", - "firmware": "2019-06-21T02:00:00+02:00", - "location": "cd143c07248f491493cea0533bc3d669", - "model": "Plug", - "name": "NVR", - "zigbee_mac_address": "ABCD012345670A15", - "vendor": "Plugwise", - "available": True, - "sensors": { - "electricity_consumed": 34.0, - "electricity_consumed_interval": 9.15, - "electricity_produced": 0.0, - "electricity_produced_interval": 0.0, - }, - "switches": {"relay": True, "lock": True}, - }, - "a28f588dc4a049a483fd03a30361ad3a": { - "dev_class": "settop", - "firmware": "2019-06-21T02:00:00+02:00", - "location": "cd143c07248f491493cea0533bc3d669", - "model": "Plug", - "name": "Fibaro HC2", - "zigbee_mac_address": "ABCD012345670A13", - "vendor": "Plugwise", - "available": True, - "sensors": { - "electricity_consumed": 12.5, - "electricity_consumed_interval": 3.8, - "electricity_produced": 0.0, - "electricity_produced_interval": 0.0, - }, - "switches": {"relay": True, "lock": True}, - }, - "6a3bf693d05e48e0b460c815a4fdd09d": { - "dev_class": "zone_thermostat", - "firmware": "2016-10-27T02:00:00+02:00", - "hardware": "255", - "location": "82fa13f017d240daa0d0ea1775420f24", - "model": "Lisa", - "name": "Zone Thermostat Jessie", - "zigbee_mac_address": "ABCD012345670A03", - "vendor": "Plugwise", - "thermostat": { - "setpoint": 15.0, - "lower_bound": 0.0, - "upper_bound": 99.9, - "resolution": 0.01, - }, - "available": True, - "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], - "active_preset": "asleep", - "available_schedules": [ - "CV Roan", - "Bios Schema met Film Avond", - "GF7 Woonkamer", - "Badkamer Schema", - "CV Jessie", - ], - "select_schedule": "CV Jessie", - "last_used": "CV Jessie", - "mode": "auto", - "sensors": {"temperature": 17.2, "setpoint": 15.0, "battery": 37}, - }, - "680423ff840043738f42cc7f1ff97a36": { - "dev_class": "thermo_sensor", - "firmware": "2019-03-27T01:00:00+01:00", - "hardware": "1", - "location": "08963fec7c53423ca5680aa4cb502c63", - "model": "Tom/Floor", - "name": "Thermostatic Radiator Badkamer", - "zigbee_mac_address": "ABCD012345670A17", - "vendor": "Plugwise", - "available": True, - "sensors": { - "temperature": 19.1, - "setpoint": 14.0, - "battery": 51, - "temperature_difference": -0.4, - "valve_position": 0.0, - }, - }, - "f1fee6043d3642a9b0a65297455f008e": { - "dev_class": "zone_thermostat", - "firmware": "2016-10-27T02:00:00+02:00", - "hardware": "255", - "location": "08963fec7c53423ca5680aa4cb502c63", - "model": "Lisa", - "name": "Zone Thermostat Badkamer", - "zigbee_mac_address": "ABCD012345670A08", - "vendor": "Plugwise", - "thermostat": { - "setpoint": 14.0, - "lower_bound": 0.0, - "upper_bound": 99.9, - "resolution": 0.01, - }, - "available": True, - "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], - "active_preset": "away", - "available_schedules": [ - "CV Roan", - "Bios Schema met Film Avond", - "GF7 Woonkamer", - "Badkamer Schema", - "CV Jessie", - ], - "select_schedule": "Badkamer Schema", - "last_used": "Badkamer Schema", - "mode": "auto", - "sensors": {"temperature": 18.9, "setpoint": 14.0, "battery": 92}, - }, - "675416a629f343c495449970e2ca37b5": { - "dev_class": "router", - "firmware": "2019-06-21T02:00:00+02:00", - "location": "cd143c07248f491493cea0533bc3d669", - "model": "Plug", - "name": "Ziggo Modem", - "zigbee_mac_address": "ABCD012345670A01", - "vendor": "Plugwise", - "available": True, - "sensors": { - "electricity_consumed": 12.2, - "electricity_consumed_interval": 2.97, - "electricity_produced": 0.0, - "electricity_produced_interval": 0.0, - }, - "switches": {"relay": True, "lock": True}, - }, - "e7693eb9582644e5b865dba8d4447cf1": { - "dev_class": "thermostatic_radiator_valve", - "firmware": "2019-03-27T01:00:00+01:00", - "hardware": "1", - "location": "446ac08dd04d4eff8ac57489757b7314", - "model": "Tom/Floor", - "name": "CV Kraan Garage", - "zigbee_mac_address": "ABCD012345670A11", - "vendor": "Plugwise", - "thermostat": { - "setpoint": 5.5, - "lower_bound": 0.0, - "upper_bound": 100.0, - "resolution": 0.01, - }, - "available": True, - "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], - "active_preset": "no_frost", - "available_schedules": [ - "CV Roan", - "Bios Schema met Film Avond", - "GF7 Woonkamer", - "Badkamer Schema", - "CV Jessie", - ], - "select_schedule": "None", - "last_used": "Badkamer Schema", - "mode": "heat", - "sensors": { - "temperature": 15.6, - "setpoint": 5.5, - "battery": 68, - "temperature_difference": 0.0, - "valve_position": 0.0, - }, - }, - }, - } + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, init_integration) + == snapshot + ) diff --git a/tests/components/plugwise/test_number.py b/tests/components/plugwise/test_number.py index 9ca64e104d3133..6572a0df20eeb8 100644 --- a/tests/components/plugwise/test_number.py +++ b/tests/components/plugwise/test_number.py @@ -38,7 +38,7 @@ async def test_anna_max_boiler_temp_change( assert mock_smile_anna.set_number_setpoint.call_count == 1 mock_smile_anna.set_number_setpoint.assert_called_with( - "maximum_boiler_temperature", 65.0 + "maximum_boiler_temperature", "1cbf783bb11e4a7c8a6843dee3a86927", 65.0 ) @@ -67,5 +67,25 @@ async def test_adam_dhw_setpoint_change( assert mock_smile_adam_2.set_number_setpoint.call_count == 1 mock_smile_adam_2.set_number_setpoint.assert_called_with( - "max_dhw_temperature", 55.0 + "max_dhw_temperature", "056ee145a816487eaa69243c3280f8bf", 55.0 + ) + + +async def test_adam_temperature_offset_change( + hass: HomeAssistant, mock_smile_adam: MagicMock, init_integration: MockConfigEntry +) -> None: + """Test changing of number entities.""" + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: "number.zone_thermostat_jessie_temperature_offset", + ATTR_VALUE: 1.0, + }, + blocking=True, + ) + + assert mock_smile_adam.set_temperature_offset.call_count == 1 + mock_smile_adam.set_temperature_offset.assert_called_with( + "temperature_offset", "6a3bf693d05e48e0b460c815a4fdd09d", 1.0 ) diff --git a/tests/components/private_ble_device/__init__.py b/tests/components/private_ble_device/__init__.py new file mode 100644 index 00000000000000..df9929293a1526 --- /dev/null +++ b/tests/components/private_ble_device/__init__.py @@ -0,0 +1,78 @@ +"""Tests for private_ble_device.""" + +from datetime import timedelta +import time +from unittest.mock import patch + +from home_assistant_bluetooth import BluetoothServiceInfoBleak + +from homeassistant import config_entries +from homeassistant.components.private_ble_device.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util + +from tests.common import MockConfigEntry, async_fire_time_changed +from tests.components.bluetooth import ( + generate_advertisement_data, + generate_ble_device, + inject_bluetooth_service_info_bleak, +) + +MAC_RPA_VALID_1 = "40:01:02:0a:c4:a6" +MAC_RPA_VALID_2 = "40:02:03:d2:74:ce" +MAC_RPA_INVALID = "40:00:00:d2:74:ce" +MAC_STATIC = "00:01:ff:a0:3a:76" + +DUMMY_IRK = "00000000000000000000000000000000" + + +async def async_mock_config_entry(hass: HomeAssistant, irk: str = DUMMY_IRK) -> None: + """Create a test device for a dummy IRK.""" + entry = MockConfigEntry( + version=1, + domain=DOMAIN, + entry_id=irk, + data={"irk": irk}, + title="Private BLE Device 000000", + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + assert entry.state is config_entries.ConfigEntryState.LOADED + await hass.async_block_till_done() + + +async def async_inject_broadcast( + hass: HomeAssistant, + mac: str = MAC_RPA_VALID_1, + mfr_data: bytes = b"", + broadcast_time: float | None = None, +) -> None: + """Inject an advertisement.""" + inject_bluetooth_service_info_bleak( + hass, + BluetoothServiceInfoBleak( + name="Test Test Test", + address=mac, + rssi=-63, + service_data={}, + manufacturer_data={1: mfr_data}, + service_uuids=[], + source="local", + device=generate_ble_device(mac, "Test Test Test"), + advertisement=generate_advertisement_data(local_name="Not it"), + time=broadcast_time or time.monotonic(), + connectable=False, + ), + ) + await hass.async_block_till_done() + + +async def async_move_time_forwards(hass: HomeAssistant, offset: float): + """Mock time advancing from now to now+offset.""" + with patch( + "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", + return_value=time.monotonic() + offset, + ): + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=offset)) + await hass.async_block_till_done() diff --git a/tests/components/private_ble_device/conftest.py b/tests/components/private_ble_device/conftest.py new file mode 100644 index 00000000000000..b33dc1d4ea2109 --- /dev/null +++ b/tests/components/private_ble_device/conftest.py @@ -0,0 +1 @@ +"""private_ble_device fixtures.""" diff --git a/tests/components/private_ble_device/test_config_flow.py b/tests/components/private_ble_device/test_config_flow.py new file mode 100644 index 00000000000000..aa8ea0d905c514 --- /dev/null +++ b/tests/components/private_ble_device/test_config_flow.py @@ -0,0 +1,132 @@ +"""Tests for private bluetooth device config flow.""" +from unittest.mock import patch + +from homeassistant import config_entries +from homeassistant.components.private_ble_device import const +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult, FlowResultType +from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo + +from tests.components.bluetooth import inject_bluetooth_service_info + + +def assert_form_error(result: FlowResult, key: str, value: str) -> None: + """Assert that a flow returned a form error.""" + assert result["type"] == "form" + assert result["errors"] + assert result["errors"][key] == value + + +async def test_setup_user_no_bluetooth( + hass: HomeAssistant, mock_bluetooth_adapters: None +) -> None: + """Test setting up via user interaction when bluetooth is not enabled.""" + result = await hass.config_entries.flow.async_init( + const.DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "bluetooth_not_available" + + +async def test_invalid_irk(hass: HomeAssistant, enable_bluetooth: None) -> None: + """Test invalid irk.""" + result = await hass.config_entries.flow.async_init( + const.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"irk": "irk:000000"} + ) + assert_form_error(result, "irk", "irk_not_valid") + + +async def test_irk_not_found(hass: HomeAssistant, enable_bluetooth: None) -> None: + """Test irk not found.""" + result = await hass.config_entries.flow.async_init( + const.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"irk": "irk:00000000000000000000000000000000"}, + ) + assert_form_error(result, "irk", "irk_not_found") + + +async def test_flow_works(hass: HomeAssistant, enable_bluetooth: None) -> None: + """Test config flow works.""" + + inject_bluetooth_service_info( + hass, + BluetoothServiceInfo( + name="Test Test Test", + address="40:01:02:0a:c4:a6", + rssi=-63, + service_data={}, + manufacturer_data={}, + service_uuids=[], + source="local", + ), + ) + + result = await hass.config_entries.flow.async_init( + const.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + + # Check you can finish the flow + with patch( + "homeassistant.components.private_ble_device.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"irk": "irk:00000000000000000000000000000000"}, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Test Test Test" + assert result["data"] == {"irk": "00000000000000000000000000000000"} + assert result["result"].unique_id == "00000000000000000000000000000000" + + +async def test_flow_works_by_base64( + hass: HomeAssistant, enable_bluetooth: None +) -> None: + """Test config flow works.""" + + inject_bluetooth_service_info( + hass, + BluetoothServiceInfo( + name="Test Test Test", + address="40:01:02:0a:c4:a6", + rssi=-63, + service_data={}, + manufacturer_data={}, + service_uuids=[], + source="local", + ), + ) + + result = await hass.config_entries.flow.async_init( + const.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + + # Check you can finish the flow + with patch( + "homeassistant.components.private_ble_device.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"irk": "AAAAAAAAAAAAAAAAAAAAAA=="}, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Test Test Test" + assert result["data"] == {"irk": "00000000000000000000000000000000"} + assert result["result"].unique_id == "00000000000000000000000000000000" diff --git a/tests/components/private_ble_device/test_device_tracker.py b/tests/components/private_ble_device/test_device_tracker.py new file mode 100644 index 00000000000000..776ba503983e8f --- /dev/null +++ b/tests/components/private_ble_device/test_device_tracker.py @@ -0,0 +1,183 @@ +"""Tests for polling measures.""" + + +import time + +from homeassistant.components.bluetooth.advertisement_tracker import ( + ADVERTISING_TIMES_NEEDED, +) +from homeassistant.core import HomeAssistant + +from . import ( + MAC_RPA_VALID_1, + MAC_RPA_VALID_2, + MAC_STATIC, + async_inject_broadcast, + async_mock_config_entry, + async_move_time_forwards, +) + +from tests.components.bluetooth.test_advertisement_tracker import ONE_HOUR_SECONDS + + +async def test_tracker_created(hass: HomeAssistant, enable_bluetooth: None) -> None: + """Test creating a tracker entity when no devices have been seen.""" + await async_mock_config_entry(hass) + + state = hass.states.get("device_tracker.private_ble_device_000000") + assert state + assert state.state == "not_home" + + +async def test_tracker_ignore_other_rpa( + hass: HomeAssistant, enable_bluetooth: None +) -> None: + """Test that tracker ignores RPA's that don't match us.""" + await async_mock_config_entry(hass) + await async_inject_broadcast(hass, MAC_STATIC) + + state = hass.states.get("device_tracker.private_ble_device_000000") + assert state + assert state.state == "not_home" + + +async def test_tracker_already_home( + hass: HomeAssistant, enable_bluetooth: None +) -> None: + """Test creating a tracker and the device was already discovered by HA.""" + await async_inject_broadcast(hass, MAC_RPA_VALID_1) + await async_mock_config_entry(hass) + + state = hass.states.get("device_tracker.private_ble_device_000000") + assert state + assert state.state == "home" + + +async def test_tracker_arrive_home(hass: HomeAssistant, enable_bluetooth: None) -> None: + """Test transition from not_home to home.""" + await async_mock_config_entry(hass) + await async_inject_broadcast(hass, MAC_RPA_VALID_1, b"1") + state = hass.states.get("device_tracker.private_ble_device_000000") + assert state + assert state.state == "home" + assert state.attributes["current_address"] == "40:01:02:0a:c4:a6" + assert state.attributes["source"] == "local" + + await async_inject_broadcast(hass, MAC_STATIC, b"1") + state = hass.states.get("device_tracker.private_ble_device_000000") + assert state + assert state.state == "home" + + # Test same wrong mac address again to exercise some caching + await async_inject_broadcast(hass, MAC_STATIC, b"2") + state = hass.states.get("device_tracker.private_ble_device_000000") + assert state + assert state.state == "home" + + # And test original mac address again. + # Use different mfr data so that event bubbles up + await async_inject_broadcast(hass, MAC_RPA_VALID_1, b"2") + state = hass.states.get("device_tracker.private_ble_device_000000") + assert state + assert state.state == "home" + assert state.attributes["current_address"] == "40:01:02:0a:c4:a6" + + +async def test_tracker_isolation(hass: HomeAssistant, enable_bluetooth: None) -> None: + """Test creating 2 tracker entities doesn't confuse anything.""" + await async_mock_config_entry(hass) + await async_mock_config_entry(hass, irk="1" * 32) + + # This broadcast should only impact the first entity + await async_inject_broadcast(hass, MAC_RPA_VALID_1, b"1") + + state = hass.states.get("device_tracker.private_ble_device_000000") + assert state + assert state.state == "home" + + state = hass.states.get("device_tracker.private_ble_device_111111") + assert state + assert state.state == "not_home" + + +async def test_tracker_mac_rotate(hass: HomeAssistant, enable_bluetooth: None) -> None: + """Test MAC address rotation.""" + await async_inject_broadcast(hass, MAC_RPA_VALID_1) + await async_mock_config_entry(hass) + + state = hass.states.get("device_tracker.private_ble_device_000000") + assert state + assert state.state == "home" + assert state.attributes["current_address"] == MAC_RPA_VALID_1 + + await async_inject_broadcast(hass, MAC_RPA_VALID_2) + state = hass.states.get("device_tracker.private_ble_device_000000") + assert state + assert state.state == "home" + assert state.attributes["current_address"] == MAC_RPA_VALID_2 + + +async def test_tracker_start_stale(hass: HomeAssistant, enable_bluetooth: None) -> None: + """Test edge case where we find an existing stale record, and it expires before we see any more.""" + time.monotonic() + + await async_inject_broadcast(hass, MAC_RPA_VALID_1) + await async_mock_config_entry(hass) + + state = hass.states.get("device_tracker.private_ble_device_000000") + assert state + assert state.state == "home" + + await async_move_time_forwards( + hass, ((ADVERTISING_TIMES_NEEDED - 1) * ONE_HOUR_SECONDS) + ) + state = hass.states.get("device_tracker.private_ble_device_000000") + assert state + assert state.state == "not_home" + + +async def test_tracker_leave_home(hass: HomeAssistant, enable_bluetooth: None) -> None: + """Test tracker notices we have left.""" + time.monotonic() + + await async_mock_config_entry(hass) + await async_inject_broadcast(hass, MAC_RPA_VALID_1) + + state = hass.states.get("device_tracker.private_ble_device_000000") + assert state + assert state.state == "home" + + await async_move_time_forwards( + hass, ((ADVERTISING_TIMES_NEEDED - 1) * ONE_HOUR_SECONDS) + ) + state = hass.states.get("device_tracker.private_ble_device_000000") + assert state + assert state.state == "not_home" + + +async def test_old_tracker_leave_home( + hass: HomeAssistant, enable_bluetooth: None +) -> None: + """Test tracker ignores an old stale mac address timing out.""" + start_time = time.monotonic() + + await async_mock_config_entry(hass) + + await async_inject_broadcast(hass, MAC_RPA_VALID_2, broadcast_time=start_time) + await async_inject_broadcast(hass, MAC_RPA_VALID_2, broadcast_time=start_time + 15) + + state = hass.states.get("device_tracker.private_ble_device_000000") + assert state + assert state.state == "home" + + # First address has timed out - still home + await async_move_time_forwards(hass, 910) + state = hass.states.get("device_tracker.private_ble_device_000000") + assert state + assert state.state == "home" + + # Second address has time out - now away + await async_move_time_forwards(hass, 920) + state = hass.states.get("device_tracker.private_ble_device_000000") + assert state + assert state.state == "not_home" diff --git a/tests/components/private_ble_device/test_sensor.py b/tests/components/private_ble_device/test_sensor.py new file mode 100644 index 00000000000000..820ec2199ad37d --- /dev/null +++ b/tests/components/private_ble_device/test_sensor.py @@ -0,0 +1,47 @@ +"""Tests for sensors.""" + + +from homeassistant.core import HomeAssistant + +from . import MAC_RPA_VALID_1, async_inject_broadcast, async_mock_config_entry + + +async def test_sensor_unavailable( + hass: HomeAssistant, + enable_bluetooth: None, + entity_registry_enabled_by_default: None, +) -> None: + """Test sensors are unavailable.""" + await async_mock_config_entry(hass) + + state = hass.states.get("sensor.private_ble_device_000000_signal_strength") + assert state + assert state.state == "unavailable" + + +async def test_sensors_already_home( + hass: HomeAssistant, + enable_bluetooth: None, + entity_registry_enabled_by_default: None, +) -> None: + """Test sensors get value when we start at home.""" + await async_inject_broadcast(hass, MAC_RPA_VALID_1) + await async_mock_config_entry(hass) + + state = hass.states.get("sensor.private_ble_device_000000_signal_strength") + assert state + assert state.state == "-63" + + +async def test_sensors_come_home( + hass: HomeAssistant, + enable_bluetooth: None, + entity_registry_enabled_by_default: None, +) -> None: + """Test sensors get value when we receive a broadcast.""" + await async_mock_config_entry(hass) + await async_inject_broadcast(hass, MAC_RPA_VALID_1) + + state = hass.states.get("sensor.private_ble_device_000000_signal_strength") + assert state + assert state.state == "-63" diff --git a/tests/components/prometheus/test_init.py b/tests/components/prometheus/test_init.py index 446666c4a6aea5..f24782b98d4d4e 100644 --- a/tests/components/prometheus/test_init.py +++ b/tests/components/prometheus/test_init.py @@ -24,6 +24,7 @@ prometheus, sensor, switch, + update, ) from homeassistant.components.climate import ( ATTR_CURRENT_TEMPERATURE, @@ -107,6 +108,34 @@ async def generate_latest_metrics(client): return body +@pytest.mark.parametrize("namespace", [""]) +async def test_setup_enumeration(hass, hass_client, entity_registry, namespace): + """Test that setup enumerates existing states/entities.""" + + # The order of when things are created must be carefully controlled in + # this test, so we don't use fixtures. + + sensor_1 = entity_registry.async_get_or_create( + domain=sensor.DOMAIN, + platform="test", + unique_id="sensor_1", + unit_of_measurement=UnitOfTemperature.CELSIUS, + original_device_class=SensorDeviceClass.TEMPERATURE, + suggested_object_id="outside_temperature", + original_name="Outside Temperature", + ) + set_state_with_entry(hass, sensor_1, 12.3, {}) + assert await async_setup_component(hass, prometheus.DOMAIN, {prometheus.DOMAIN: {}}) + + client = await hass_client() + body = await generate_latest_metrics(client) + assert ( + 'homeassistant_sensor_temperature_celsius{domain="sensor",' + 'entity="sensor.outside_temperature",' + 'friendly_name="Outside Temperature"} 12.3' in body + ) + + @pytest.mark.parametrize("namespace", [""]) async def test_view_empty_namespace(client, sensor_entities) -> None: """Test prometheus metrics view.""" @@ -232,6 +261,12 @@ async def test_sensor_device_class(client, sensor_entities) -> None: 'friendly_name="Radio Energy"} 14.0' in body ) + assert ( + 'sensor_timestamp_seconds{domain="sensor",' + 'entity="sensor.timestamp",' + 'friendly_name="Timestamp"} 1.691445808136036e+09' in body + ) + @pytest.mark.parametrize("namespace", [""]) async def test_input_number(client, input_number_entities) -> None: @@ -538,6 +573,23 @@ async def test_counter(client, counter_entities) -> None: ) +@pytest.mark.parametrize("namespace", [""]) +async def test_update(client, update_entities) -> None: + """Test prometheus metrics for update.""" + body = await generate_latest_metrics(client) + + assert ( + 'update_state{domain="update",' + 'entity="update.firmware",' + 'friendly_name="Firmware"} 1.0' in body + ) + assert ( + 'update_state{domain="update",' + 'entity="update.addon",' + 'friendly_name="Addon"} 0.0' in body + ) + + @pytest.mark.parametrize("namespace", [""]) async def test_renaming_entity_name( hass: HomeAssistant, @@ -1049,6 +1101,16 @@ async def sensor_fixture( set_state_with_entry(hass, sensor_11, 50) data["sensor_11"] = sensor_11 + sensor_12 = entity_registry.async_get_or_create( + domain=sensor.DOMAIN, + platform="test", + unique_id="sensor_12", + original_device_class=SensorDeviceClass.TIMESTAMP, + suggested_object_id="Timestamp", + original_name="Timestamp", + ) + set_state_with_entry(hass, sensor_12, "2023-08-07T15:03:28.136036-0700") + data["sensor_12"] = sensor_12 await hass.async_block_till_done() return data @@ -1547,6 +1609,36 @@ async def counter_fixture( return data +@pytest.fixture(name="update_entities") +async def update_fixture( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> dict[str, er.RegistryEntry]: + """Simulate update entities.""" + data = {} + update_1 = entity_registry.async_get_or_create( + domain=update.DOMAIN, + platform="test", + unique_id="update_1", + suggested_object_id="firmware", + original_name="Firmware", + ) + set_state_with_entry(hass, update_1, STATE_ON) + data["update_1"] = update_1 + + update_2 = entity_registry.async_get_or_create( + domain=update.DOMAIN, + platform="test", + unique_id="update_2", + suggested_object_id="addon", + original_name="Addon", + ) + set_state_with_entry(hass, update_2, STATE_OFF) + data["update_2"] = update_2 + + await hass.async_block_till_done() + return data + + def set_state_with_entry( hass: HomeAssistant, entry: er.RegistryEntry, diff --git a/tests/components/pure_energie/test_config_flow.py b/tests/components/pure_energie/test_config_flow.py index 2b00e975a8eef1..992ce8bbb2c8e5 100644 --- a/tests/components/pure_energie/test_config_flow.py +++ b/tests/components/pure_energie/test_config_flow.py @@ -1,4 +1,5 @@ """Test the Pure Energie config flow.""" +from ipaddress import ip_address from unittest.mock import MagicMock from gridnet import GridNetConnectionError @@ -47,8 +48,8 @@ async def test_full_zeroconf_flow_implementationn( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.123", - addresses=["192.168.1.123"], + ip_address=ip_address("192.168.1.123"), + ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", name="mock_name", port=None, @@ -103,8 +104,8 @@ async def test_zeroconf_connection_error( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.123", - addresses=["192.168.1.123"], + ip_address=ip_address("192.168.1.123"), + ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", name="mock_name", port=None, diff --git a/tests/components/rachio/test_config_flow.py b/tests/components/rachio/test_config_flow.py index 8d66725d20e12c..26083f51e634e0 100644 --- a/tests/components/rachio/test_config_flow.py +++ b/tests/components/rachio/test_config_flow.py @@ -1,4 +1,5 @@ """Test the Rachio config flow.""" +from ipaddress import ip_address from unittest.mock import MagicMock, patch from homeassistant import config_entries @@ -114,8 +115,8 @@ async def test_form_homekit(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, data=zeroconf.ZeroconfServiceInfo( - host="mock_host", - addresses=["mock_host"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="mock_name", port=None, @@ -139,8 +140,8 @@ async def test_form_homekit(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, data=zeroconf.ZeroconfServiceInfo( - host="mock_host", - addresses=["mock_host"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="mock_name", port=None, @@ -165,8 +166,8 @@ async def test_form_homekit_ignored(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, data=zeroconf.ZeroconfServiceInfo( - host="mock_host", - addresses=["mock_host"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="mock_name", port=None, diff --git a/tests/components/rainmachine/test_config_flow.py b/tests/components/rainmachine/test_config_flow.py index 0d95cbcce31242..5fa457bf771a01 100644 --- a/tests/components/rainmachine/test_config_flow.py +++ b/tests/components/rainmachine/test_config_flow.py @@ -1,4 +1,5 @@ """Define tests for the OpenUV config flow.""" +from ipaddress import ip_address from unittest.mock import patch import pytest @@ -157,8 +158,8 @@ async def test_step_homekit_zeroconf_ip_already_exists( DOMAIN, context={"source": source}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.100", - addresses=["192.168.1.100"], + ip_address=ip_address("192.168.1.100"), + ip_addresses=[ip_address("192.168.1.100")], hostname="mock_hostname", name="mock_name", port=None, @@ -185,8 +186,8 @@ async def test_step_homekit_zeroconf_ip_change( DOMAIN, context={"source": source}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.2", - addresses=["192.168.1.2"], + ip_address=ip_address("192.168.1.2"), + ip_addresses=[ip_address("192.168.1.2")], hostname="mock_hostname", name="mock_name", port=None, @@ -214,8 +215,8 @@ async def test_step_homekit_zeroconf_new_controller_when_some_exist( DOMAIN, context={"source": source}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.100", - addresses=["192.168.1.100"], + ip_address=ip_address("192.168.1.100"), + ip_addresses=[ip_address("192.168.1.100")], hostname="mock_hostname", name="mock_name", port=None, @@ -264,8 +265,8 @@ async def test_discovery_by_homekit_and_zeroconf_same_time( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.100", - addresses=["192.168.1.100"], + ip_address=ip_address("192.168.1.100"), + ip_addresses=[ip_address("192.168.1.100")], hostname="mock_hostname", name="mock_name", port=None, @@ -284,8 +285,8 @@ async def test_discovery_by_homekit_and_zeroconf_same_time( DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.100", - addresses=["192.168.1.100"], + ip_address=ip_address("192.168.1.100"), + ip_addresses=[ip_address("192.168.1.100")], hostname="mock_hostname", name="mock_name", port=None, diff --git a/tests/components/recorder/test_migration_from_schema_32.py b/tests/components/recorder/test_migration_from_schema_32.py index cdf930fde261c1..e007d2408dd70e 100644 --- a/tests/components/recorder/test_migration_from_schema_32.py +++ b/tests/components/recorder/test_migration_from_schema_32.py @@ -39,6 +39,12 @@ ORIG_TZ = dt_util.DEFAULT_TIME_ZONE +async def _async_wait_migration_done(hass: HomeAssistant) -> None: + """Wait for the migration to be done.""" + await recorder.get_instance(hass).async_block_till_done() + await async_recorder_block_till_done(hass) + + def _create_engine_test(*args, **kwargs): """Test version of create_engine that initializes with old schema. @@ -101,6 +107,8 @@ async def test_migrate_events_context_ids( """Test we can migrate old uuid context ids and ulid context ids to binary format.""" instance = await async_setup_recorder_instance(hass) await async_wait_recording_done(hass) + importlib.import_module(SCHEMA_MODULE) + old_db_schema = sys.modules[SCHEMA_MODULE] test_uuid = uuid.uuid4() uuid_hex = test_uuid.hex @@ -110,7 +118,7 @@ def _insert_events(): with session_scope(hass=hass) as session: session.add_all( ( - Events( + old_db_schema.Events( event_type="old_uuid_context_id_event", event_data=None, origin_idx=0, @@ -123,7 +131,7 @@ def _insert_events(): context_parent_id=None, context_parent_id_bin=None, ), - Events( + old_db_schema.Events( event_type="empty_context_id_event", event_data=None, origin_idx=0, @@ -136,7 +144,7 @@ def _insert_events(): context_parent_id=None, context_parent_id_bin=None, ), - Events( + old_db_schema.Events( event_type="ulid_context_id_event", event_data=None, origin_idx=0, @@ -149,7 +157,7 @@ def _insert_events(): context_parent_id="01ARZ3NDEKTSV4RRFFQ69G5FA2", context_parent_id_bin=None, ), - Events( + old_db_schema.Events( event_type="invalid_context_id_event", event_data=None, origin_idx=0, @@ -162,7 +170,7 @@ def _insert_events(): context_parent_id=None, context_parent_id_bin=None, ), - Events( + old_db_schema.Events( event_type="garbage_context_id_event", event_data=None, origin_idx=0, @@ -175,7 +183,7 @@ def _insert_events(): context_parent_id=None, context_parent_id_bin=None, ), - Events( + old_db_schema.Events( event_type="event_with_garbage_context_id_no_time_fired_ts", event_data=None, origin_idx=0, @@ -196,10 +204,12 @@ def _insert_events(): await async_wait_recording_done(hass) now = dt_util.utcnow() expected_ulid_fallback_start = ulid_to_bytes(ulid_at_time(now.timestamp()))[0:6] + await _async_wait_migration_done(hass) + with freeze_time(now): # This is a threadsafe way to add a task to the recorder instance.queue_task(EventsContextIDMigrationTask()) - await async_recorder_block_till_done(hass) + await _async_wait_migration_done(hass) def _object_as_dict(obj): return {c.key: getattr(obj, c.key) for c in inspect(obj).mapper.column_attrs} @@ -304,6 +314,8 @@ async def test_migrate_states_context_ids( """Test we can migrate old uuid context ids and ulid context ids to binary format.""" instance = await async_setup_recorder_instance(hass) await async_wait_recording_done(hass) + importlib.import_module(SCHEMA_MODULE) + old_db_schema = sys.modules[SCHEMA_MODULE] test_uuid = uuid.uuid4() uuid_hex = test_uuid.hex @@ -313,7 +325,7 @@ def _insert_states(): with session_scope(hass=hass) as session: session.add_all( ( - States( + old_db_schema.States( entity_id="state.old_uuid_context_id", last_updated_ts=1477721632.452529, context_id=uuid_hex, @@ -323,7 +335,7 @@ def _insert_states(): context_parent_id=None, context_parent_id_bin=None, ), - States( + old_db_schema.States( entity_id="state.empty_context_id", last_updated_ts=1477721632.552529, context_id=None, @@ -333,7 +345,7 @@ def _insert_states(): context_parent_id=None, context_parent_id_bin=None, ), - States( + old_db_schema.States( entity_id="state.ulid_context_id", last_updated_ts=1477721632.552529, context_id="01ARZ3NDEKTSV4RRFFQ69G5FAV", @@ -343,7 +355,7 @@ def _insert_states(): context_parent_id="01ARZ3NDEKTSV4RRFFQ69G5FA2", context_parent_id_bin=None, ), - States( + old_db_schema.States( entity_id="state.invalid_context_id", last_updated_ts=1477721632.552529, context_id="invalid", @@ -353,7 +365,7 @@ def _insert_states(): context_parent_id=None, context_parent_id_bin=None, ), - States( + old_db_schema.States( entity_id="state.garbage_context_id", last_updated_ts=1477721632.552529, context_id="adapt_lgt:b'5Cf*':interval:b'0R'", @@ -363,7 +375,7 @@ def _insert_states(): context_parent_id=None, context_parent_id_bin=None, ), - States( + old_db_schema.States( entity_id="state.human_readable_uuid_context_id", last_updated_ts=1477721632.552529, context_id="0ae29799-ee4e-4f45-8116-f582d7d3ee65", @@ -380,7 +392,7 @@ def _insert_states(): await async_wait_recording_done(hass) instance.queue_task(StatesContextIDMigrationTask()) - await async_recorder_block_till_done(hass) + await _async_wait_migration_done(hass) def _object_as_dict(obj): return {c.key: getattr(obj, c.key) for c in inspect(obj).mapper.column_attrs} @@ -489,22 +501,24 @@ async def test_migrate_event_type_ids( """Test we can migrate event_types to the EventTypes table.""" instance = await async_setup_recorder_instance(hass) await async_wait_recording_done(hass) + importlib.import_module(SCHEMA_MODULE) + old_db_schema = sys.modules[SCHEMA_MODULE] def _insert_events(): with session_scope(hass=hass) as session: session.add_all( ( - Events( + old_db_schema.Events( event_type="event_type_one", origin_idx=0, time_fired_ts=1677721632.452529, ), - Events( + old_db_schema.Events( event_type="event_type_one", origin_idx=0, time_fired_ts=1677721632.552529, ), - Events( + old_db_schema.Events( event_type="event_type_two", origin_idx=0, time_fired_ts=1677721632.552529, @@ -517,7 +531,7 @@ def _insert_events(): await async_wait_recording_done(hass) # This is a threadsafe way to add a task to the recorder instance.queue_task(EventTypeIDMigrationTask()) - await async_recorder_block_till_done(hass) + await _async_wait_migration_done(hass) def _fetch_migrated_events(): with session_scope(hass=hass, read_only=True) as session: @@ -570,22 +584,24 @@ async def test_migrate_entity_ids( """Test we can migrate entity_ids to the StatesMeta table.""" instance = await async_setup_recorder_instance(hass) await async_wait_recording_done(hass) + importlib.import_module(SCHEMA_MODULE) + old_db_schema = sys.modules[SCHEMA_MODULE] def _insert_states(): with session_scope(hass=hass) as session: session.add_all( ( - States( + old_db_schema.States( entity_id="sensor.one", state="one_1", last_updated_ts=1.452529, ), - States( + old_db_schema.States( entity_id="sensor.two", state="two_2", last_updated_ts=2.252529, ), - States( + old_db_schema.States( entity_id="sensor.two", state="two_1", last_updated_ts=3.152529, @@ -595,10 +611,10 @@ def _insert_states(): await instance.async_add_executor_job(_insert_states) - await async_wait_recording_done(hass) + await _async_wait_migration_done(hass) # This is a threadsafe way to add a task to the recorder instance.queue_task(EntityIDMigrationTask()) - await async_recorder_block_till_done(hass) + await _async_wait_migration_done(hass) def _fetch_migrated_states(): with session_scope(hass=hass, read_only=True) as session: @@ -636,22 +652,24 @@ async def test_post_migrate_entity_ids( """Test we can migrate entity_ids to the StatesMeta table.""" instance = await async_setup_recorder_instance(hass) await async_wait_recording_done(hass) + importlib.import_module(SCHEMA_MODULE) + old_db_schema = sys.modules[SCHEMA_MODULE] def _insert_events(): with session_scope(hass=hass) as session: session.add_all( ( - States( + old_db_schema.States( entity_id="sensor.one", state="one_1", last_updated_ts=1.452529, ), - States( + old_db_schema.States( entity_id="sensor.two", state="two_2", last_updated_ts=2.252529, ), - States( + old_db_schema.States( entity_id="sensor.two", state="two_1", last_updated_ts=3.152529, @@ -661,10 +679,10 @@ def _insert_events(): await instance.async_add_executor_job(_insert_events) - await async_wait_recording_done(hass) + await _async_wait_migration_done(hass) # This is a threadsafe way to add a task to the recorder instance.queue_task(EntityIDPostMigrationTask()) - await async_recorder_block_till_done(hass) + await _async_wait_migration_done(hass) def _fetch_migrated_states(): with session_scope(hass=hass, read_only=True) as session: @@ -688,18 +706,20 @@ async def test_migrate_null_entity_ids( """Test we can migrate entity_ids to the StatesMeta table.""" instance = await async_setup_recorder_instance(hass) await async_wait_recording_done(hass) + importlib.import_module(SCHEMA_MODULE) + old_db_schema = sys.modules[SCHEMA_MODULE] def _insert_states(): with session_scope(hass=hass) as session: session.add( - States( + old_db_schema.States( entity_id="sensor.one", state="one_1", last_updated_ts=1.452529, ), ) session.add_all( - States( + old_db_schema.States( entity_id=None, state="empty", last_updated_ts=time + 1.452529, @@ -707,7 +727,7 @@ def _insert_states(): for time in range(1000) ) session.add( - States( + old_db_schema.States( entity_id="sensor.one", state="one_1", last_updated_ts=2.452529, @@ -716,11 +736,10 @@ def _insert_states(): await instance.async_add_executor_job(_insert_states) - await async_wait_recording_done(hass) + await _async_wait_migration_done(hass) # This is a threadsafe way to add a task to the recorder instance.queue_task(EntityIDMigrationTask()) - await async_recorder_block_till_done(hass) - await async_recorder_block_till_done(hass) + await _async_wait_migration_done(hass) def _fetch_migrated_states(): with session_scope(hass=hass, read_only=True) as session: @@ -758,18 +777,20 @@ async def test_migrate_null_event_type_ids( """Test we can migrate event_types to the EventTypes table when the event_type is NULL.""" instance = await async_setup_recorder_instance(hass) await async_wait_recording_done(hass) + importlib.import_module(SCHEMA_MODULE) + old_db_schema = sys.modules[SCHEMA_MODULE] def _insert_events(): with session_scope(hass=hass) as session: session.add( - Events( + old_db_schema.Events( event_type="event_type_one", origin_idx=0, time_fired_ts=1.452529, ), ) session.add_all( - Events( + old_db_schema.Events( event_type=None, origin_idx=0, time_fired_ts=time + 1.452529, @@ -777,7 +798,7 @@ def _insert_events(): for time in range(1000) ) session.add( - Events( + old_db_schema.Events( event_type="event_type_one", origin_idx=0, time_fired_ts=2.452529, @@ -786,12 +807,10 @@ def _insert_events(): await instance.async_add_executor_job(_insert_events) - await async_wait_recording_done(hass) + await _async_wait_migration_done(hass) # This is a threadsafe way to add a task to the recorder - instance.queue_task(EventTypeIDMigrationTask()) - await async_recorder_block_till_done(hass) - await async_recorder_block_till_done(hass) + await _async_wait_migration_done(hass) def _fetch_migrated_events(): with session_scope(hass=hass, read_only=True) as session: diff --git a/tests/components/reolink/test_config_flow.py b/tests/components/reolink/test_config_flow.py index 048b48d9576a39..1a4bf999ccebca 100644 --- a/tests/components/reolink/test_config_flow.py +++ b/tests/components/reolink/test_config_flow.py @@ -1,18 +1,22 @@ """Test the Reolink config flow.""" +from datetime import timedelta import json -from unittest.mock import MagicMock +from typing import Any +from unittest.mock import AsyncMock, MagicMock import pytest from reolink_aio.exceptions import ApiError, CredentialsInvalidError, ReolinkError from homeassistant import config_entries, data_entry_flow from homeassistant.components import dhcp -from homeassistant.components.reolink import const +from homeassistant.components.reolink import DEVICE_UPDATE_INTERVAL, const from homeassistant.components.reolink.config_flow import DEFAULT_PROTOCOL from homeassistant.components.reolink.exceptions import ReolinkWebhookException +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import format_mac +from homeassistant.util.dt import utcnow from .conftest import ( TEST_HOST, @@ -27,12 +31,14 @@ TEST_USERNAME2, ) -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed -pytestmark = pytest.mark.usefixtures("mock_setup_entry", "reolink_connect") +pytestmark = pytest.mark.usefixtures("reolink_connect") -async def test_config_flow_manual_success(hass: HomeAssistant) -> None: +async def test_config_flow_manual_success( + hass: HomeAssistant, mock_setup_entry: MagicMock +) -> None: """Successful flow manually initialized by the user.""" result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -66,7 +72,7 @@ async def test_config_flow_manual_success(hass: HomeAssistant) -> None: async def test_config_flow_errors( - hass: HomeAssistant, reolink_connect: MagicMock + hass: HomeAssistant, reolink_connect: MagicMock, mock_setup_entry: MagicMock ) -> None: """Successful flow manually initialized by the user after some errors.""" result = await hass.config_entries.flow.async_init( @@ -192,7 +198,7 @@ async def test_config_flow_errors( } -async def test_options_flow(hass: HomeAssistant) -> None: +async def test_options_flow(hass: HomeAssistant, mock_setup_entry: MagicMock) -> None: """Test specifying non default settings using options flow.""" config_entry = MockConfigEntry( domain=const.DOMAIN, @@ -230,7 +236,9 @@ async def test_options_flow(hass: HomeAssistant) -> None: } -async def test_change_connection_settings(hass: HomeAssistant) -> None: +async def test_change_connection_settings( + hass: HomeAssistant, mock_setup_entry: MagicMock +) -> None: """Test changing connection settings by issuing a second user config flow.""" config_entry = MockConfigEntry( domain=const.DOMAIN, @@ -273,7 +281,7 @@ async def test_change_connection_settings(hass: HomeAssistant) -> None: assert config_entry.data[CONF_PASSWORD] == TEST_PASSWORD2 -async def test_reauth(hass: HomeAssistant) -> None: +async def test_reauth(hass: HomeAssistant, mock_setup_entry: MagicMock) -> None: """Test a reauth flow.""" config_entry = MockConfigEntry( domain=const.DOMAIN, @@ -333,7 +341,7 @@ async def test_reauth(hass: HomeAssistant) -> None: assert config_entry.data[CONF_PASSWORD] == TEST_PASSWORD2 -async def test_dhcp_flow(hass: HomeAssistant) -> None: +async def test_dhcp_flow(hass: HomeAssistant, mock_setup_entry: MagicMock) -> None: """Successful flow from DHCP discovery.""" dhcp_data = dhcp.DhcpServiceInfo( ip=TEST_HOST, @@ -371,8 +379,44 @@ async def test_dhcp_flow(hass: HomeAssistant) -> None: } -async def test_dhcp_abort_flow(hass: HomeAssistant) -> None: - """Test dhcp discovery aborts if already configured.""" +@pytest.mark.parametrize( + ("last_update_success", "attr", "value", "expected"), + [ + ( + False, + None, + None, + TEST_HOST2, + ), + ( + True, + None, + None, + TEST_HOST, + ), + ( + False, + "get_state", + AsyncMock(side_effect=ReolinkError("Test error")), + TEST_HOST, + ), + ( + False, + "mac_address", + "aa:aa:aa:aa:aa:aa", + TEST_HOST, + ), + ], +) +async def test_dhcp_ip_update( + hass: HomeAssistant, + reolink_connect: MagicMock, + last_update_success: bool, + attr: str, + value: Any, + expected: str, +) -> None: + """Test dhcp discovery aborts if already configured where the IP is updated if appropriate.""" config_entry = MockConfigEntry( domain=const.DOMAIN, unique_id=format_mac(TEST_MAC), @@ -392,16 +436,31 @@ async def test_dhcp_abort_flow(hass: HomeAssistant) -> None: assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() + assert config_entry.state == ConfigEntryState.LOADED + + if not last_update_success: + # ensure the last_update_succes is False for the device_coordinator. + reolink_connect.get_states = AsyncMock(side_effect=ReolinkError("Test error")) + async_fire_time_changed( + hass, utcnow() + DEVICE_UPDATE_INTERVAL + timedelta(minutes=1) + ) + await hass.async_block_till_done() dhcp_data = dhcp.DhcpServiceInfo( - ip=TEST_HOST, + ip=TEST_HOST2, hostname="Reolink", macaddress=TEST_MAC, ) + if attr is not None: + setattr(reolink_connect, attr, value) + result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=dhcp_data ) assert result["type"] is data_entry_flow.FlowResultType.ABORT assert result["reason"] == "already_configured" + + await hass.async_block_till_done() + assert config_entry.data[CONF_HOST] == expected diff --git a/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py index ef841769f8d0ce..3435bd58cb39f6 100644 --- a/tests/components/roborock/conftest.py +++ b/tests/components/roborock/conftest.py @@ -24,9 +24,21 @@ def bypass_api_fixture() -> None: "homeassistant.components.roborock.RoborockMqttClient.async_connect" ), patch( "homeassistant.components.roborock.RoborockMqttClient._send_command" + ), patch( + "homeassistant.components.roborock.RoborockApiClient.get_home_data", + return_value=HOME_DATA, + ), patch( + "homeassistant.components.roborock.RoborockMqttClient.get_networking", + return_value=NETWORK_INFO, ), patch( "homeassistant.components.roborock.coordinator.RoborockLocalClient.get_prop", return_value=PROP, + ), patch( + "homeassistant.components.roborock.coordinator.RoborockLocalClient.send_message" + ), patch( + "homeassistant.components.roborock.RoborockMqttClient._wait_response" + ), patch( + "homeassistant.components.roborock.coordinator.RoborockLocalClient._wait_response" ), patch( "roborock.api.AttributeCache.async_value" ), patch( @@ -53,25 +65,11 @@ def mock_roborock_entry(hass: HomeAssistant) -> MockConfigEntry: @pytest.fixture async def setup_entry( - hass: HomeAssistant, mock_roborock_entry: MockConfigEntry + hass: HomeAssistant, + bypass_api_fixture, + mock_roborock_entry: MockConfigEntry, ) -> MockConfigEntry: """Set up the Roborock platform.""" - with patch( - "homeassistant.components.roborock.RoborockApiClient.get_home_data", - return_value=HOME_DATA, - ), patch( - "homeassistant.components.roborock.RoborockMqttClient.get_networking", - return_value=NETWORK_INFO, - ), patch( - "homeassistant.components.roborock.coordinator.RoborockLocalClient.get_prop", - return_value=PROP, - ), patch( - "homeassistant.components.roborock.coordinator.RoborockLocalClient.send_message" - ), patch( - "homeassistant.components.roborock.RoborockMqttClient._wait_response" - ), patch( - "homeassistant.components.roborock.coordinator.RoborockLocalClient._wait_response" - ): - assert await async_setup_component(hass, DOMAIN, {}) + assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() return mock_roborock_entry diff --git a/tests/components/roborock/mock_data.py b/tests/components/roborock/mock_data.py index 6a2e1f4b5f1849..87ed02bc3ecc0a 100644 --- a/tests/components/roborock/mock_data.py +++ b/tests/components/roborock/mock_data.py @@ -13,6 +13,9 @@ ) from roborock.roborock_typing import DeviceProp +from homeassistant.components.roborock import CONF_BASE_URL, CONF_USER_DATA +from homeassistant.const import CONF_USERNAME + # All data is based on a U.S. customer with a Roborock S7 MaxV Ultra USER_EMAIL = "user@domain.com" @@ -48,9 +51,9 @@ ) MOCK_CONFIG = { - "username": USER_EMAIL, - "user_data": USER_DATA.as_dict(), - "base_url": None, + CONF_USERNAME: USER_EMAIL, + CONF_USER_DATA: USER_DATA.as_dict(), + CONF_BASE_URL: None, } HOME_DATA_RAW = { @@ -61,7 +64,7 @@ "geoName": None, "products": [ { - "id": "abc123", + "id": "s7_product", "name": "Roborock S7 MaxV", "code": "a27", "model": "roborock.vacuum.a27", @@ -227,7 +230,7 @@ "runtimeEnv": None, "timeZoneId": "America/Los_Angeles", "iconUrl": "", - "productId": "abc123", + "productId": "s7_product", "lon": None, "lat": None, "share": False, @@ -255,7 +258,45 @@ "120": 0, }, "silentOtaSwitch": True, - } + }, + { + "duid": "device_2", + "name": "Roborock S7 2", + "attribute": None, + "activeTime": 1672364449, + "localKey": "device_2", + "runtimeEnv": None, + "timeZoneId": "America/Los_Angeles", + "iconUrl": "", + "productId": "s7_product", + "lon": None, + "lat": None, + "share": False, + "shareTime": None, + "online": True, + "fv": "02.56.02", + "pv": "1.0", + "roomId": 2362003, + "tuyaUuid": None, + "tuyaMigrated": False, + "extra": '{"RRPhotoPrivacyVersion": "1"}', + "sn": "abc123", + "featureSet": "2234201184108543", + "newFeatureSet": "0000000000002041", + "deviceStatus": { + "121": 8, + "122": 100, + "123": 102, + "124": 203, + "125": 94, + "126": 90, + "127": 87, + "128": 0, + "133": 1, + "120": 0, + }, + "silentOtaSwitch": True, + }, ], "receivedDevices": [], "rooms": [ diff --git a/tests/components/roborock/snapshots/test_diagnostics.ambr b/tests/components/roborock/snapshots/test_diagnostics.ambr index eb70e04110f29a..d8e5f7d4cb23ef 100644 --- a/tests/components/roborock/snapshots/test_diagnostics.ambr +++ b/tests/components/roborock/snapshots/test_diagnostics.ambr @@ -57,7 +57,7 @@ 'name': 'Roborock S7 MaxV', 'newFeatureSet': '0000000000002041', 'online': True, - 'productId': 'abc123', + 'productId': 's7_product', 'pv': '1.0', 'roomId': 2362003, 'share': False, @@ -77,7 +77,7 @@ 'capability': 0, 'category': 'robot.vacuum.cleaner', 'code': 'a27', - 'id': 'abc123', + 'id': 's7_product', 'model': 'roborock.vacuum.a27', 'name': 'Roborock S7 MaxV', 'schema': list([ @@ -225,11 +225,274 @@ 'area': 20965000, 'avoidCount': 19, 'begin': 1672543330, + 'beginDatetime': '2023-01-01T03:22:10+00:00', 'cleanType': 3, 'complete': 1, 'duration': 1176, 'dustCollectionStatus': 1, 'end': 1672544638, + 'endDatetime': '2023-01-01T03:43:58+00:00', + 'error': 0, + 'finishReason': 56, + 'mapFlag': 0, + 'squareMeterArea': 21.0, + 'startType': 2, + 'washCount': 2, + }), + 'status': dict({ + 'adbumperStatus': list([ + 0, + 0, + 0, + ]), + 'autoDustCollection': 1, + 'avoidCount': 19, + 'backType': -1, + 'battery': 100, + 'cameraStatus': 3457, + 'chargeStatus': 1, + 'cleanArea': 20965000, + 'cleanTime': 1176, + 'collisionAvoidStatus': 1, + 'debugMode': 0, + 'dndEnabled': 0, + 'dockErrorStatus': 0, + 'dockType': 3, + 'dustCollectionStatus': 0, + 'errorCode': 0, + 'fanPower': 102, + 'homeSecEnablePassword': 0, + 'homeSecStatus': 0, + 'inCleaning': 0, + 'inFreshState': 1, + 'inReturning': 0, + 'isExploring': 0, + 'isLocating': 0, + 'labStatus': 1, + 'lockStatus': 0, + 'mapPresent': 1, + 'mapStatus': 3, + 'mopForbiddenEnable': 1, + 'mopMode': 300, + 'msgSeq': 458, + 'msgVer': 2, + 'squareMeterCleanArea': 21.0, + 'state': 8, + 'switchMapMode': 0, + 'unsaveMapFlag': 0, + 'unsaveMapReason': 0, + 'washPhase': 0, + 'washReady': 0, + 'waterBoxCarriageStatus': 1, + 'waterBoxMode': 203, + 'waterBoxStatus': 1, + 'waterShortageStatus': 0, + }), + }), + }), + }), + '**REDACTED-1**': dict({ + 'api': dict({ + }), + 'roborock_device_info': dict({ + 'device': dict({ + 'activeTime': 1672364449, + 'deviceStatus': dict({ + '120': 0, + '121': 8, + '122': 100, + '123': 102, + '124': 203, + '125': 94, + '126': 90, + '127': 87, + '128': 0, + '133': 1, + }), + 'duid': '**REDACTED**', + 'extra': '{"RRPhotoPrivacyVersion": "1"}', + 'featureSet': '2234201184108543', + 'fv': '02.56.02', + 'iconUrl': '', + 'localKey': '**REDACTED**', + 'name': 'Roborock S7 2', + 'newFeatureSet': '0000000000002041', + 'online': True, + 'productId': 's7_product', + 'pv': '1.0', + 'roomId': 2362003, + 'share': False, + 'silentOtaSwitch': True, + 'sn': 'abc123', + 'timeZoneId': 'America/Los_Angeles', + 'tuyaMigrated': False, + }), + 'network_info': dict({ + 'bssid': '**REDACTED**', + 'ip': '123.232.12.1', + 'mac': '**REDACTED**', + 'rssi': 90, + 'ssid': 'wifi', + }), + 'product': dict({ + 'capability': 0, + 'category': 'robot.vacuum.cleaner', + 'code': 'a27', + 'id': 's7_product', + 'model': 'roborock.vacuum.a27', + 'name': 'Roborock S7 MaxV', + 'schema': list([ + dict({ + 'code': 'rpc_request', + 'id': '101', + 'mode': 'rw', + 'name': 'rpc_request', + 'type': 'RAW', + }), + dict({ + 'code': 'rpc_response', + 'id': '102', + 'mode': 'rw', + 'name': 'rpc_response', + 'type': 'RAW', + }), + dict({ + 'code': 'error_code', + 'id': '120', + 'mode': 'ro', + 'name': '错误代码', + 'type': 'ENUM', + }), + dict({ + 'code': 'state', + 'id': '121', + 'mode': 'ro', + 'name': '设备状态', + 'type': 'ENUM', + }), + dict({ + 'code': 'battery', + 'id': '122', + 'mode': 'ro', + 'name': '设备电量', + 'type': 'ENUM', + }), + dict({ + 'code': 'fan_power', + 'id': '123', + 'mode': 'rw', + 'name': '清扫模式', + 'type': 'ENUM', + }), + dict({ + 'code': 'water_box_mode', + 'id': '124', + 'mode': 'rw', + 'name': '拖地模式', + 'type': 'ENUM', + }), + dict({ + 'code': 'main_brush_life', + 'id': '125', + 'mode': 'rw', + 'name': '主刷寿命', + 'type': 'VALUE', + }), + dict({ + 'code': 'side_brush_life', + 'id': '126', + 'mode': 'rw', + 'name': '边刷寿命', + 'type': 'VALUE', + }), + dict({ + 'code': 'filter_life', + 'id': '127', + 'mode': 'rw', + 'name': '滤网寿命', + 'type': 'VALUE', + }), + dict({ + 'code': 'additional_props', + 'id': '128', + 'mode': 'ro', + 'name': '额外状态', + 'type': 'RAW', + }), + dict({ + 'code': 'task_complete', + 'id': '130', + 'mode': 'ro', + 'name': '完成事件', + 'type': 'RAW', + }), + dict({ + 'code': 'task_cancel_low_power', + 'id': '131', + 'mode': 'ro', + 'name': '电量不足任务取消', + 'type': 'RAW', + }), + dict({ + 'code': 'task_cancel_in_motion', + 'id': '132', + 'mode': 'ro', + 'name': '运动中任务取消', + 'type': 'RAW', + }), + dict({ + 'code': 'charge_status', + 'id': '133', + 'mode': 'ro', + 'name': '充电状态', + 'type': 'RAW', + }), + dict({ + 'code': 'drying_status', + 'id': '134', + 'mode': 'ro', + 'name': '烘干状态', + 'type': 'RAW', + }), + ]), + }), + 'props': dict({ + 'cleanSummary': dict({ + 'cleanArea': 1159182500, + 'cleanCount': 31, + 'cleanTime': 74382, + 'dustCollectionCount': 25, + 'records': list([ + 1672543330, + 1672458041, + ]), + 'squareMeterCleanArea': 1159.2, + }), + 'consumable': dict({ + 'cleaningBrushWorkTimes': 65, + 'dustCollectionWorkTimes': 25, + 'filterElementWorkTime': 0, + 'filterTimeLeft': 465618, + 'filterWorkTime': 74382, + 'mainBrushTimeLeft': 1005618, + 'mainBrushWorkTime': 74382, + 'sensorDirtyTime': 74382, + 'sensorTimeLeft': 33618, + 'sideBrushTimeLeft': 645618, + 'sideBrushWorkTime': 74382, + 'strainerWorkTimes': 65, + }), + 'lastCleanRecord': dict({ + 'area': 20965000, + 'avoidCount': 19, + 'begin': 1672543330, + 'beginDatetime': '2023-01-01T03:22:10+00:00', + 'cleanType': 3, + 'complete': 1, + 'duration': 1176, + 'dustCollectionStatus': 1, + 'end': 1672544638, + 'endDatetime': '2023-01-01T03:43:58+00:00', 'error': 0, 'finishReason': 56, 'mapFlag': 0, diff --git a/tests/components/roborock/test_binary_sensor.py b/tests/components/roborock/test_binary_sensor.py new file mode 100644 index 00000000000000..4edf8ff4710001 --- /dev/null +++ b/tests/components/roborock/test_binary_sensor.py @@ -0,0 +1,20 @@ +"""Test Roborock Binary Sensor.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_binary_sensors( + hass: HomeAssistant, setup_entry: MockConfigEntry +) -> None: + """Test binary sensors and check test values are correctly set.""" + assert len(hass.states.async_all("binary_sensor")) == 6 + assert hass.states.get("binary_sensor.roborock_s7_maxv_mop_attached").state == "on" + assert ( + hass.states.get("binary_sensor.roborock_s7_maxv_water_box_attached").state + == "on" + ) + assert ( + hass.states.get("binary_sensor.roborock_s7_maxv_water_shortage").state == "off" + ) diff --git a/tests/components/roborock/test_init.py b/tests/components/roborock/test_init.py index 05bf0848475840..a5ad24b431c548 100644 --- a/tests/components/roborock/test_init.py +++ b/tests/components/roborock/test_init.py @@ -21,7 +21,7 @@ async def test_unload_entry( ) as mock_disconnect: assert await hass.config_entries.async_unload(setup_entry.entry_id) await hass.async_block_till_done() - assert mock_disconnect.call_count == 1 + assert mock_disconnect.call_count == 2 assert setup_entry.state is ConfigEntryState.NOT_LOADED assert not hass.data.get(DOMAIN) diff --git a/tests/components/roborock/test_sensor.py b/tests/components/roborock/test_sensor.py index 19648343bb48d2..35fcc9478cdd92 100644 --- a/tests/components/roborock/test_sensor.py +++ b/tests/components/roborock/test_sensor.py @@ -14,7 +14,7 @@ async def test_sensors(hass: HomeAssistant, setup_entry: MockConfigEntry) -> None: """Test sensors and check test values are correctly set.""" - assert len(hass.states.async_all("sensor")) == 11 + assert len(hass.states.async_all("sensor")) == 28 assert hass.states.get("sensor.roborock_s7_maxv_main_brush_time_left").state == str( MAIN_BRUSH_REPLACE_TIME - 74382 ) @@ -38,3 +38,12 @@ async def test_sensors(hass: HomeAssistant, setup_entry: MockConfigEntry) -> Non assert hass.states.get("sensor.roborock_s7_maxv_cleaning_area").state == "21.0" assert hass.states.get("sensor.roborock_s7_maxv_vacuum_error").state == "none" assert hass.states.get("sensor.roborock_s7_maxv_battery").state == "100" + assert hass.states.get("sensor.roborock_s7_maxv_dock_error").state == "ok" + assert ( + hass.states.get("sensor.roborock_s7_maxv_last_clean_begin").state + == "2023-01-01T03:22:10+00:00" + ) + assert ( + hass.states.get("sensor.roborock_s7_maxv_last_clean_end").state + == "2023-01-01T03:43:58+00:00" + ) diff --git a/tests/components/roku/__init__.py b/tests/components/roku/__init__.py index 2ae0b308f9acc4..fc12bb9731dd90 100644 --- a/tests/components/roku/__init__.py +++ b/tests/components/roku/__init__.py @@ -1,4 +1,6 @@ """Tests for the Roku component.""" +from ipaddress import ip_address + from homeassistant.components import ssdp, zeroconf from homeassistant.components.ssdp import ATTR_UPNP_FRIENDLY_NAME, ATTR_UPNP_SERIAL @@ -23,8 +25,8 @@ HOMEKIT_HOST = "192.168.1.161" MOCK_HOMEKIT_DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( - host=HOMEKIT_HOST, - addresses=[HOMEKIT_HOST], + ip_address=ip_address(HOMEKIT_HOST), + ip_addresses=[ip_address(HOMEKIT_HOST)], hostname="mock_hostname", name="onn._hap._tcp.local.", port=None, diff --git a/tests/components/roomba/test_config_flow.py b/tests/components/roomba/test_config_flow.py index 0b39c34d3b8cbc..f62ca1a73b93b7 100644 --- a/tests/components/roomba/test_config_flow.py +++ b/tests/components/roomba/test_config_flow.py @@ -1,4 +1,5 @@ """Test the iRobot Roomba config flow.""" +from ipaddress import ip_address from unittest.mock import MagicMock, PropertyMock, patch import pytest @@ -36,25 +37,25 @@ ( config_entries.SOURCE_ZEROCONF, zeroconf.ZeroconfServiceInfo( - host=MOCK_IP, + ip_address=ip_address(MOCK_IP), + ip_addresses=[ip_address(MOCK_IP)], hostname="irobot-blid.local.", name="irobot-blid._amzn-alexa._tcp.local.", type="_amzn-alexa._tcp.local.", port=443, properties={}, - addresses=[MOCK_IP], ), ), ( config_entries.SOURCE_ZEROCONF, zeroconf.ZeroconfServiceInfo( - host=MOCK_IP, + ip_address=ip_address(MOCK_IP), + ip_addresses=[ip_address(MOCK_IP)], hostname="roomba-blid.local.", name="roomba-blid._amzn-alexa._tcp.local.", type="_amzn-alexa._tcp.local.", port=443, properties={}, - addresses=[MOCK_IP], ), ), ] diff --git a/tests/components/ruckus_unleashed/test_config_flow.py b/tests/components/ruckus_unleashed/test_config_flow.py index c55d531b0cb7be..cd74395fa66569 100644 --- a/tests/components/ruckus_unleashed/test_config_flow.py +++ b/tests/components/ruckus_unleashed/test_config_flow.py @@ -1,4 +1,5 @@ """Test the Ruckus Unleashed config flow.""" +from copy import deepcopy from datetime import timedelta from unittest.mock import AsyncMock, patch @@ -10,12 +11,22 @@ from aioruckus.exceptions import AuthenticationError from homeassistant import config_entries, data_entry_flow -from homeassistant.components.ruckus_unleashed.const import DOMAIN +from homeassistant.components.ruckus_unleashed.const import ( + API_SYS_SYSINFO, + API_SYS_SYSINFO_SERIAL, + DOMAIN, +) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.util import utcnow -from . import CONFIG, DEFAULT_TITLE, RuckusAjaxApiPatchContext, mock_config_entry +from . import ( + CONFIG, + DEFAULT_SYSTEM_INFO, + DEFAULT_TITLE, + RuckusAjaxApiPatchContext, + mock_config_entry, +) from tests.common import async_fire_time_changed @@ -25,7 +36,7 @@ async def test_form(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] == data_entry_flow.FlowResultType.FORM assert result["errors"] == {} with RuckusAjaxApiPatchContext(), patch( @@ -37,12 +48,12 @@ async def test_form(hass: HomeAssistant) -> None: CONFIG, ) await hass.async_block_till_done() - - assert result2["type"] == "create_entry" - assert result2["title"] == DEFAULT_TITLE - assert result2["data"] == CONFIG assert len(mock_setup_entry.mock_calls) == 1 + assert result2["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result2["title"] == DEFAULT_TITLE + assert result2["data"] == CONFIG + async def test_form_invalid_auth(hass: HomeAssistant) -> None: """Test we handle invalid auth.""" @@ -58,7 +69,7 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None: CONFIG, ) - assert result2["type"] == "form" + assert result2["type"] == data_entry_flow.FlowResultType.FORM assert result2["errors"] == {"base": "invalid_auth"} @@ -68,7 +79,131 @@ async def test_form_user_reauth(hass: HomeAssistant) -> None: entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_REAUTH} + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": entry.entry_id, + "unique_id": entry.unique_id, + }, + data=entry.data, + ) + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert "flow_id" in flows[0] + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + with RuckusAjaxApiPatchContext(): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "1.2.3.4", + CONF_USERNAME: "new_name", + CONF_PASSWORD: "new_pass", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" + + +async def test_form_user_reauth_different_unique_id(hass: HomeAssistant) -> None: + """Test reauth.""" + entry = mock_config_entry() + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": entry.entry_id, + "unique_id": entry.unique_id, + }, + data=entry.data, + ) + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert "flow_id" in flows[0] + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + system_info = deepcopy(DEFAULT_SYSTEM_INFO) + system_info[API_SYS_SYSINFO][API_SYS_SYSINFO_SERIAL] = "000000000" + with RuckusAjaxApiPatchContext(system_info=system_info): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "1.2.3.4", + CONF_USERNAME: "new_name", + CONF_PASSWORD: "new_pass", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.FlowResultType.FORM + assert result2["errors"] == {"base": "invalid_host"} + + +async def test_form_user_reauth_invalid_auth(hass: HomeAssistant) -> None: + """Test reauth.""" + entry = mock_config_entry() + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": entry.entry_id, + "unique_id": entry.unique_id, + }, + data=entry.data, + ) + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert "flow_id" in flows[0] + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + with RuckusAjaxApiPatchContext( + login_mock=AsyncMock(side_effect=AuthenticationError(ERROR_LOGIN_INCORRECT)) + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "1.2.3.4", + CONF_USERNAME: "new_name", + CONF_PASSWORD: "new_pass", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.FlowResultType.FORM + assert result2["errors"] == {"base": "invalid_auth"} + + +async def test_form_user_reauth_cannot_connect(hass: HomeAssistant) -> None: + """Test reauth.""" + entry = mock_config_entry() + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": entry.entry_id, + "unique_id": entry.unique_id, + }, + data=entry.data, ) flows = hass.config_entries.flow.async_progress() @@ -76,20 +211,63 @@ async def test_form_user_reauth(hass: HomeAssistant) -> None: assert "flow_id" in flows[0] assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "reauth_confirm" - - result2 = await hass.config_entries.flow.async_configure( - flows[0]["flow_id"], - user_input={ - CONF_HOST: "1.2.3.4", - CONF_USERNAME: "new_name", - CONF_PASSWORD: "new_pass", + assert result["step_id"] == "user" + assert result["errors"] == {} + + with RuckusAjaxApiPatchContext( + login_mock=AsyncMock(side_effect=ConnectionError(ERROR_CONNECT_TIMEOUT)) + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "1.2.3.4", + CONF_USERNAME: "new_name", + CONF_PASSWORD: "new_pass", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.FlowResultType.FORM + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_user_reauth_general_exception(hass: HomeAssistant) -> None: + """Test reauth.""" + entry = mock_config_entry() + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": entry.entry_id, + "unique_id": entry.unique_id, }, + data=entry.data, ) - await hass.async_block_till_done() + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert "flow_id" in flows[0] + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + with RuckusAjaxApiPatchContext(login_mock=AsyncMock(side_effect=Exception)): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "1.2.3.4", + CONF_USERNAME: "new_name", + CONF_PASSWORD: "new_pass", + }, + ) + await hass.async_block_till_done() + assert result2["type"] == data_entry_flow.FlowResultType.FORM assert result2["step_id"] == "user" + assert result2["errors"] == {"base": "unknown"} async def test_form_cannot_connect(hass: HomeAssistant) -> None: @@ -106,45 +284,44 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: CONFIG, ) - assert result2["type"] == "form" + assert result2["type"] == data_entry_flow.FlowResultType.FORM assert result2["errors"] == {"base": "cannot_connect"} -async def test_form_unexpected_response(hass: HomeAssistant) -> None: - """Test we handle unknown error.""" +async def test_form_general_exception(hass: HomeAssistant) -> None: + """Test we handle cannot connect error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with RuckusAjaxApiPatchContext( - login_mock=AsyncMock( - side_effect=ConnectionRefusedError(ERROR_CONNECT_TEMPORARY) - ) - ): + with RuckusAjaxApiPatchContext(login_mock=AsyncMock(side_effect=Exception)): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], CONFIG, ) - assert result2["type"] == "form" - assert result2["errors"] == {"base": "cannot_connect"} + assert result2["type"] == data_entry_flow.FlowResultType.FORM + assert result2["step_id"] == "user" + assert result2["errors"] == {"base": "unknown"} -async def test_form_cannot_connect_unknown_serial(hass: HomeAssistant) -> None: - """Test we handle cannot connect error on invalid serial number.""" +async def test_form_unexpected_response(hass: HomeAssistant) -> None: + """Test we handle unknown error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == "form" - assert result["errors"] == {} - with RuckusAjaxApiPatchContext(system_info={}): + with RuckusAjaxApiPatchContext( + login_mock=AsyncMock( + side_effect=ConnectionRefusedError(ERROR_CONNECT_TEMPORARY) + ) + ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], CONFIG, ) - assert result2["type"] == "form" + assert result2["type"] == data_entry_flow.FlowResultType.FORM assert result2["errors"] == {"base": "cannot_connect"} @@ -167,7 +344,7 @@ async def test_form_duplicate_error(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] == data_entry_flow.FlowResultType.FORM assert result["errors"] == {} result2 = await hass.config_entries.flow.async_configure( @@ -175,5 +352,5 @@ async def test_form_duplicate_error(hass: HomeAssistant) -> None: CONFIG, ) - assert result2["type"] == "abort" + assert result2["type"] == data_entry_flow.FlowResultType.ABORT assert result2["reason"] == "already_configured" diff --git a/tests/components/samsungtv/snapshots/test_init.ambr b/tests/components/samsungtv/snapshots/test_init.ambr new file mode 100644 index 00000000000000..f8b11bd864a102 --- /dev/null +++ b/tests/components/samsungtv/snapshots/test_init.ambr @@ -0,0 +1,55 @@ +# serializer version: 1 +# 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_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': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.any', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'any', + 'platform': 'samsungtv', + '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 3c4b982b000a75..a70a0042fcd310 100644 --- a/tests/components/samsungtv/test_config_flow.py +++ b/tests/components/samsungtv/test_config_flow.py @@ -1,4 +1,5 @@ """Tests for Samsung TV config flow.""" +from ipaddress import ip_address from unittest.mock import ANY, AsyncMock, Mock, call, patch import pytest @@ -130,8 +131,8 @@ ) EXISTING_IP = "192.168.40.221" MOCK_ZEROCONF_DATA = zeroconf.ZeroconfServiceInfo( - host="fake_host", - addresses=["fake_host"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="mock_name", port=1234, @@ -975,7 +976,7 @@ async def test_zeroconf(hass: HomeAssistant) -> None: ) assert result["type"] == "create_entry" assert result["title"] == "Living Room (82GXARRS)" - assert result["data"][CONF_HOST] == "fake_host" + assert result["data"][CONF_HOST] == "127.0.0.1" assert result["data"][CONF_NAME] == "Living Room" assert result["data"][CONF_MAC] == "aa:bb:ww:ii:ff:ii" assert result["data"][CONF_MANUFACTURER] == "Samsung" @@ -1273,7 +1274,9 @@ 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, unique_id=None) + entry = MockConfigEntry( + domain=DOMAIN, data={**MOCK_OLD_ENTRY, "host": "127.0.0.1"}, unique_id=None + ) entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( @@ -1539,7 +1542,7 @@ async def test_update_missing_mac_added_unique_id_preserved_from_zeroconf( """Test missing mac and unique id added.""" entry = MockConfigEntry( domain=DOMAIN, - data=MOCK_OLD_ENTRY, + data={**MOCK_OLD_ENTRY, "host": "127.0.0.1"}, unique_id="0d1cef00-00dc-1000-9c80-4844f7b172de", ) entry.add_to_hass(hass) diff --git a/tests/components/samsungtv/test_init.py b/tests/components/samsungtv/test_init.py index 7491f3b76b79ce..526f7a12fedcb0 100644 --- a/tests/components/samsungtv/test_init.py +++ b/tests/components/samsungtv/test_init.py @@ -2,6 +2,7 @@ from unittest.mock import Mock, patch import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.media_player import DOMAIN, MediaPlayerEntityFeature from homeassistant.components.samsungtv.const import ( @@ -30,6 +31,7 @@ SERVICE_VOLUME_UP, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from . import setup_samsungtv_entry from .const import ( @@ -115,9 +117,13 @@ async def test_setup_h_j_model( @pytest.mark.usefixtures("remotews", "remoteencws_failing", "rest_api") -async def test_setup_updates_from_ssdp(hass: HomeAssistant) -> None: +async def test_setup_updates_from_ssdp( + hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion +) -> None: """Test setting up the entry fetches data from ssdp cache.""" - entry = MockConfigEntry(domain="samsungtv", data=MOCK_ENTRYDATA_WS) + entry = MockConfigEntry( + domain="samsungtv", data=MOCK_ENTRYDATA_WS, entry_id="sample-entry-id" + ) entry.add_to_hass(hass) async def _mock_async_get_discovery_info_by_st(hass: HomeAssistant, mock_st: str): @@ -135,7 +141,8 @@ async def _mock_async_get_discovery_info_by_st(hass: HomeAssistant, mock_st: str await hass.async_block_till_done() await hass.async_block_till_done() - assert hass.states.get("media_player.any") + 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" diff --git a/tests/components/schlage/conftest.py b/tests/components/schlage/conftest.py index 0078e6a5553d63..7b610a6b4da850 100644 --- a/tests/components/schlage/conftest.py +++ b/tests/components/schlage/conftest.py @@ -85,4 +85,5 @@ def mock_lock(): ) mock_lock.logs.return_value = [] mock_lock.last_changed_by.return_value = "thumbturn" + mock_lock.keypad_disabled.return_value = False return mock_lock diff --git a/tests/components/schlage/test_binary_sensor.py b/tests/components/schlage/test_binary_sensor.py new file mode 100644 index 00000000000000..4673f263c8cf05 --- /dev/null +++ b/tests/components/schlage/test_binary_sensor.py @@ -0,0 +1,53 @@ +"""Test Schlage binary_sensor.""" + +from datetime import timedelta +from unittest.mock import Mock + +from pyschlage.exceptions import UnknownError + +from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.util.dt import utcnow + +from tests.common import async_fire_time_changed + + +async def test_keypad_disabled_binary_sensor( + hass: HomeAssistant, mock_lock: Mock, mock_added_config_entry: ConfigEntry +) -> None: + """Test the keypad_disabled binary_sensor.""" + mock_lock.keypad_disabled.reset_mock() + mock_lock.keypad_disabled.return_value = True + + # Make the coordinator refresh data. + async_fire_time_changed(hass, utcnow() + timedelta(seconds=31)) + await hass.async_block_till_done() + + keypad = hass.states.get("binary_sensor.vault_door_keypad_disabled") + assert keypad is not None + assert keypad.state == "on" + assert keypad.attributes["device_class"] == BinarySensorDeviceClass.PROBLEM + + mock_lock.keypad_disabled.assert_called_once_with([]) + + +async def test_keypad_disabled_binary_sensor_use_previous_logs_on_failure( + hass: HomeAssistant, mock_lock: Mock, mock_added_config_entry: ConfigEntry +) -> None: + """Test the keypad_disabled binary_sensor.""" + mock_lock.keypad_disabled.reset_mock() + mock_lock.keypad_disabled.return_value = True + mock_lock.logs.reset_mock() + mock_lock.logs.side_effect = UnknownError("Cannot load logs") + + # Make the coordinator refresh data. + async_fire_time_changed(hass, utcnow() + timedelta(seconds=31)) + await hass.async_block_till_done() + + keypad = hass.states.get("binary_sensor.vault_door_keypad_disabled") + assert keypad is not None + assert keypad.state == "on" + assert keypad.attributes["device_class"] == BinarySensorDeviceClass.PROBLEM + + mock_lock.keypad_disabled.assert_called_once_with([]) diff --git a/tests/components/screenlogic/__init__.py b/tests/components/screenlogic/__init__.py index ad2b82960f0662..48362722312f01 100644 --- a/tests/components/screenlogic/__init__.py +++ b/tests/components/screenlogic/__init__.py @@ -1 +1,67 @@ """Tests for the Screenlogic integration.""" +from collections.abc import Callable +import logging + +from tests.common import load_json_object_fixture + +MOCK_ADAPTER_NAME = "Pentair DD-EE-FF" +MOCK_ADAPTER_MAC = "aa:bb:cc:dd:ee:ff" +MOCK_ADAPTER_IP = "127.0.0.1" +MOCK_ADAPTER_PORT = 80 + +_LOGGER = logging.getLogger(__name__) + + +GATEWAY_DISCOVERY_IMPORT_PATH = "homeassistant.components.screenlogic.coordinator.async_discover_gateways_by_unique_id" + + +def num_key_string_to_int(data: dict) -> None: + """Convert all string number dict keys to integer. + + This needed for screenlogicpy's data dict format. + """ + rpl = [] + for key, value in data.items(): + if isinstance(value, dict): + num_key_string_to_int(value) + if isinstance(key, str) and key.isnumeric(): + rpl.append(key) + for k in rpl: + data[int(k)] = data.pop(k) + + return data + + +DATA_FULL_CHEM = num_key_string_to_int( + load_json_object_fixture("screenlogic/data_full_chem.json") +) +DATA_MIN_MIGRATION = num_key_string_to_int( + load_json_object_fixture("screenlogic/data_min_migration.json") +) +DATA_MIN_ENTITY_CLEANUP = num_key_string_to_int( + load_json_object_fixture("screenlogic/data_min_entity_cleanup.json") +) + + +async def stub_async_connect( + data, + self, + ip=None, + port=None, + gtype=None, + gsubtype=None, + name=MOCK_ADAPTER_NAME, + connection_closed_callback: Callable = None, +) -> bool: + """Initialize minimum attributes needed for tests.""" + self._ip = ip + self._port = port + self._type = gtype + self._subtype = gsubtype + self._name = name + self._custom_connection_closed_callback = connection_closed_callback + self._mac = MOCK_ADAPTER_MAC + self._data = data + _LOGGER.debug("Gateway mock connected") + + return True diff --git a/tests/components/screenlogic/conftest.py b/tests/components/screenlogic/conftest.py new file mode 100644 index 00000000000000..3795df3dddc858 --- /dev/null +++ b/tests/components/screenlogic/conftest.py @@ -0,0 +1,27 @@ +"""Setup fixtures for ScreenLogic integration tests.""" +import pytest + +from homeassistant.components.screenlogic import DOMAIN +from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT, CONF_SCAN_INTERVAL + +from . import MOCK_ADAPTER_IP, MOCK_ADAPTER_MAC, MOCK_ADAPTER_NAME, MOCK_ADAPTER_PORT + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return a mocked config entry.""" + return MockConfigEntry( + title=MOCK_ADAPTER_NAME, + domain=DOMAIN, + data={ + CONF_IP_ADDRESS: MOCK_ADAPTER_IP, + CONF_PORT: MOCK_ADAPTER_PORT, + }, + options={ + CONF_SCAN_INTERVAL: 30, + }, + unique_id=MOCK_ADAPTER_MAC, + entry_id="screenlogictest", + ) diff --git a/tests/components/screenlogic/fixtures/data_full_chem.json b/tests/components/screenlogic/fixtures/data_full_chem.json new file mode 100644 index 00000000000000..6c9ece22fcfc11 --- /dev/null +++ b/tests/components/screenlogic/fixtures/data_full_chem.json @@ -0,0 +1,880 @@ +{ + "adapter": { + "firmware": { + "name": "Protocol Adapter Firmware", + "value": "POOL: 5.2 Build 736.0 Rel" + } + }, + "controller": { + "controller_id": 100, + "configuration": { + "body_type": { + "0": { + "min_setpoint": 40, + "max_setpoint": 104 + }, + "1": { + "min_setpoint": 40, + "max_setpoint": 104 + } + }, + "is_celsius": { + "name": "Is Celsius", + "value": 0 + }, + "controller_type": 13, + "hardware_type": 0, + "controller_data": 0, + "generic_circuit_name": "Water Features", + "circuit_count": 11, + "color_count": 8, + "color": [ + { + "name": "White", + "value": [255, 255, 255] + }, + { + "name": "Light Green", + "value": [160, 255, 160] + }, + { + "name": "Green", + "value": [0, 255, 80] + }, + { + "name": "Cyan", + "value": [0, 255, 200] + }, + { + "name": "Blue", + "value": [100, 140, 255] + }, + { + "name": "Lavender", + "value": [230, 130, 255] + }, + { + "name": "Magenta", + "value": [255, 0, 128] + }, + { + "name": "Light Magenta", + "value": [255, 180, 210] + } + ], + "interface_tab_flags": 127, + "show_alarms": 0, + "remotes": 0, + "unknown_at_offset_09": 0, + "unknown_at_offset_10": 0, + "unknown_at_offset_11": 0 + }, + "model": { + "name": "Model", + "value": "EasyTouch2 8" + }, + "equipment": { + "flags": 98360, + "list": [ + "INTELLIBRITE", + "INTELLIFLO_0", + "INTELLIFLO_1", + "INTELLICHEM", + "HYBRID_HEATER" + ] + }, + "sensor": { + "state": { + "name": "Controller State", + "value": 1, + "device_type": "enum", + "enum_options": ["Unknown", "Ready", "Sync", "Service"] + }, + "freeze_mode": { + "name": "Freeze Mode", + "value": 0 + }, + "pool_delay": { + "name": "Pool Delay", + "value": 0 + }, + "spa_delay": { + "name": "Spa Delay", + "value": 0 + }, + "cleaner_delay": { + "name": "Cleaner Delay", + "value": 0 + }, + "air_temperature": { + "name": "Air Temperature", + "value": 69, + "unit": "\u00b0F", + "device_type": "temperature", + "state_type": "measurement" + }, + "ph": { + "name": "pH", + "value": 7.61, + "unit": "pH", + "state_type": "measurement" + }, + "orp": { + "name": "ORP", + "value": 728, + "unit": "mV", + "state_type": "measurement" + }, + "saturation": { + "name": "Saturation Index", + "value": 0.06, + "unit": "lsi", + "state_type": "measurement" + }, + "salt_ppm": { + "name": "Salt", + "value": 0, + "unit": "ppm", + "state_type": "measurement" + }, + "ph_supply_level": { + "name": "pH Supply Level", + "value": 2, + "state_type": "measurement" + }, + "orp_supply_level": { + "name": "ORP Supply Level", + "value": 3, + "state_type": "measurement" + }, + "active_alert": { + "name": "Active Alert", + "value": 0, + "device_type": "alarm" + } + } + }, + "circuit": { + "500": { + "circuit_id": 500, + "name": "Spa", + "configuration": { + "name_index": 71, + "flags": 1, + "default_runtime": 720, + "unknown_at_offset_62": 0, + "unknown_at_offset_63": 0, + "delay": 0 + }, + "function": 1, + "interface": 1, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 1, + "value": 0 + }, + "501": { + "circuit_id": 501, + "name": "Waterfall", + "configuration": { + "name_index": 85, + "flags": 0, + "default_runtime": 720, + "unknown_at_offset_94": 0, + "unknown_at_offset_95": 0, + "delay": 0 + }, + "function": 0, + "interface": 2, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 2, + "value": 0 + }, + "502": { + "circuit_id": 502, + "name": "Pool Light", + "configuration": { + "name_index": 62, + "flags": 0, + "default_runtime": 720, + "unknown_at_offset_126": 0, + "unknown_at_offset_127": 0, + "delay": 0 + }, + "function": 16, + "interface": 3, + "color": { + "color_set": 2, + "color_position": 0, + "color_stagger": 2 + }, + "device_id": 3, + "value": 0 + }, + "503": { + "circuit_id": 503, + "name": "Spa Light", + "configuration": { + "name_index": 73, + "flags": 0, + "default_runtime": 720, + "unknown_at_offset_158": 0, + "unknown_at_offset_159": 0, + "delay": 0 + }, + "function": 16, + "interface": 3, + "color": { + "color_set": 6, + "color_position": 1, + "color_stagger": 10 + }, + "device_id": 4, + "value": 0 + }, + "504": { + "circuit_id": 504, + "name": "Cleaner", + "configuration": { + "name_index": 21, + "flags": 0, + "default_runtime": 240, + "unknown_at_offset_186": 0, + "unknown_at_offset_187": 0, + "delay": 0 + }, + "function": 5, + "interface": 0, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 5, + "value": 0 + }, + "505": { + "circuit_id": 505, + "name": "Pool Low", + "configuration": { + "name_index": 63, + "flags": 1, + "default_runtime": 720, + "unknown_at_offset_214": 0, + "unknown_at_offset_215": 0, + "delay": 0 + }, + "function": 2, + "interface": 0, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 6, + "value": 0 + }, + "506": { + "circuit_id": 506, + "name": "Yard Light", + "configuration": { + "name_index": 91, + "flags": 0, + "default_runtime": 720, + "unknown_at_offset_246": 0, + "unknown_at_offset_247": 0, + "delay": 0 + }, + "function": 7, + "interface": 4, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 7, + "value": 0 + }, + "507": { + "circuit_id": 507, + "name": "Cameras", + "configuration": { + "name_index": 101, + "flags": 0, + "default_runtime": 1620, + "unknown_at_offset_274": 0, + "unknown_at_offset_275": 0, + "delay": 0 + }, + "function": 0, + "interface": 2, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 8, + "value": 1 + }, + "508": { + "circuit_id": 508, + "name": "Pool High", + "configuration": { + "name_index": 61, + "flags": 0, + "default_runtime": 720, + "unknown_at_offset_306": 0, + "unknown_at_offset_307": 0, + "delay": 0 + }, + "function": 0, + "interface": 0, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 9, + "value": 0 + }, + "510": { + "circuit_id": 510, + "name": "Spillway", + "configuration": { + "name_index": 78, + "flags": 0, + "default_runtime": 720, + "unknown_at_offset_334": 0, + "unknown_at_offset_335": 0, + "delay": 0 + }, + "function": 14, + "interface": 1, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 11, + "value": 0 + }, + "511": { + "circuit_id": 511, + "name": "Pool High", + "configuration": { + "name_index": 61, + "flags": 0, + "default_runtime": 720, + "unknown_at_offset_366": 0, + "unknown_at_offset_367": 0, + "delay": 0 + }, + "function": 0, + "interface": 5, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 12, + "value": 0 + } + }, + "pump": { + "0": { + "data": 70, + "type": 3, + "state": { + "name": "Pool Low Pump", + "value": 0 + }, + "watts_now": { + "name": "Pool Low Pump Watts Now", + "value": 0, + "unit": "W", + "device_type": "power", + "state_type": "measurement" + }, + "rpm_now": { + "name": "Pool Low Pump RPM Now", + "value": 0, + "unit": "rpm", + "state_type": "measurement" + }, + "unknown_at_offset_16": 0, + "gpm_now": { + "name": "Pool Low Pump GPM Now", + "value": 0, + "unit": "gpm", + "state_type": "measurement" + }, + "unknown_at_offset_24": 255, + "preset": { + "0": { + "device_id": 6, + "setpoint": 63, + "is_rpm": 0 + }, + "1": { + "device_id": 9, + "setpoint": 72, + "is_rpm": 0 + }, + "2": { + "device_id": 1, + "setpoint": 3450, + "is_rpm": 1 + }, + "3": { + "device_id": 130, + "setpoint": 75, + "is_rpm": 0 + }, + "4": { + "device_id": 12, + "setpoint": 72, + "is_rpm": 0 + }, + "5": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + }, + "6": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + }, + "7": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + } + } + }, + "1": { + "data": 66, + "type": 3, + "state": { + "name": "Waterfall Pump", + "value": 0 + }, + "watts_now": { + "name": "Waterfall Pump Watts Now", + "value": 0, + "unit": "W", + "device_type": "power", + "state_type": "measurement" + }, + "rpm_now": { + "name": "Waterfall Pump RPM Now", + "value": 0, + "unit": "rpm", + "state_type": "measurement" + }, + "unknown_at_offset_16": 0, + "gpm_now": { + "name": "Waterfall Pump GPM Now", + "value": 0, + "unit": "gpm", + "state_type": "measurement" + }, + "unknown_at_offset_24": 255, + "preset": { + "0": { + "device_id": 2, + "setpoint": 2700, + "is_rpm": 1 + }, + "1": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + }, + "2": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + }, + "3": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + }, + "4": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + }, + "5": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + }, + "6": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + }, + "7": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + } + } + }, + "2": { + "data": 0 + }, + "3": { + "data": 0 + }, + "4": { + "data": 0 + }, + "5": { + "data": 0 + }, + "6": { + "data": 0 + }, + "7": { + "data": 0 + } + }, + "body": { + "0": { + "body_type": 0, + "min_setpoint": 40, + "max_setpoint": 104, + "name": "Pool", + "last_temperature": { + "name": "Last Pool Temperature", + "value": 81, + "unit": "\u00b0F", + "device_type": "temperature", + "state_type": "measurement" + }, + "heat_state": { + "name": "Pool Heat", + "value": 0, + "device_type": "enum", + "enum_options": ["Off", "Solar", "Heater", "Both"] + }, + "heat_setpoint": { + "name": "Pool Heat Set Point", + "value": 83, + "unit": "\u00b0F", + "device_type": "temperature" + }, + "cool_setpoint": { + "name": "Pool Cool Set Point", + "value": 100, + "unit": "\u00b0F", + "device_type": "temperature" + }, + "heat_mode": { + "name": "Pool Heat Mode", + "value": 0, + "device_type": "enum", + "enum_options": [ + "Off", + "Solar", + "Solar Preferred", + "Heater", + "Don't Change" + ] + } + }, + "1": { + "body_type": 1, + "min_setpoint": 40, + "max_setpoint": 104, + "name": "Spa", + "last_temperature": { + "name": "Last Spa Temperature", + "value": 84, + "unit": "\u00b0F", + "device_type": "temperature", + "state_type": "measurement" + }, + "heat_state": { + "name": "Spa Heat", + "value": 0, + "device_type": "enum", + "enum_options": ["Off", "Solar", "Heater", "Both"] + }, + "heat_setpoint": { + "name": "Spa Heat Set Point", + "value": 94, + "unit": "\u00b0F", + "device_type": "temperature" + }, + "cool_setpoint": { + "name": "Spa Cool Set Point", + "value": 69, + "unit": "\u00b0F", + "device_type": "temperature" + }, + "heat_mode": { + "name": "Spa Heat Mode", + "value": 0, + "device_type": "enum", + "enum_options": [ + "Off", + "Solar", + "Solar Preferred", + "Heater", + "Don't Change" + ] + } + } + }, + "intellichem": { + "unknown_at_offset_00": 42, + "unknown_at_offset_04": 0, + "sensor": { + "ph_now": { + "name": "pH Now", + "value": 0.0, + "unit": "pH", + "state_type": "measurement" + }, + "orp_now": { + "name": "ORP Now", + "value": 0, + "unit": "mV", + "state_type": "measurement" + }, + "ph_supply_level": { + "name": "pH Supply Level", + "value": 2, + "state_type": "measurement" + }, + "orp_supply_level": { + "name": "ORP Supply Level", + "value": 3, + "state_type": "measurement" + }, + "saturation": { + "name": "Saturation Index", + "value": 0.06, + "unit": "lsi", + "state_type": "measurement" + }, + "ph_probe_water_temp": { + "name": "pH Probe Water Temperature", + "value": 81, + "unit": "\u00b0F", + "device_type": "temperature", + "state_type": "measurement" + } + }, + "configuration": { + "ph_setpoint": { + "name": "pH Setpoint", + "value": 7.6, + "unit": "pH" + }, + "orp_setpoint": { + "name": "ORP Setpoint", + "value": 720, + "unit": "mV" + }, + "calcium_harness": { + "name": "Calcium Hardness", + "value": 800, + "unit": "ppm" + }, + "cya": { + "name": "Cyanuric Acid", + "value": 45, + "unit": "ppm" + }, + "total_alkalinity": { + "name": "Total Alkalinity", + "value": 45, + "unit": "ppm" + }, + "salt_tds_ppm": { + "name": "Salt/TDS", + "value": 1000, + "unit": "ppm" + }, + "probe_is_celsius": 0, + "flags": 32 + }, + "dose_status": { + "ph_last_dose_time": { + "name": "Last pH Dose Time", + "value": 5, + "unit": "sec", + "device_type": "duration", + "state_type": "total_increasing" + }, + "orp_last_dose_time": { + "name": "Last ORP Dose Time", + "value": 4, + "unit": "sec", + "device_type": "duration", + "state_type": "total_increasing" + }, + "ph_last_dose_volume": { + "name": "Last pH Dose Volume", + "value": 8, + "unit": "mL", + "device_type": "volume", + "state_type": "total_increasing" + }, + "orp_last_dose_volume": { + "name": "Last ORP Dose Volume", + "value": 8, + "unit": "mL", + "device_type": "volume", + "state_type": "total_increasing" + }, + "flags": 149, + "ph_dosing_state": { + "name": "pH Dosing State", + "value": 1, + "device_type": "enum", + "enum_options": ["Dosing", "Mixing", "Monitoring"] + }, + "orp_dosing_state": { + "name": "ORP Dosing State", + "value": 2, + "device_type": "enum", + "enum_options": ["Dosing", "Mixing", "Monitoring"] + } + }, + "alarm": { + "flags": 1, + "flow_alarm": { + "name": "Flow Alarm", + "value": 1, + "device_type": "alarm" + }, + "ph_high_alarm": { + "name": "pH HIGH Alarm", + "value": 0, + "device_type": "alarm" + }, + "ph_low_alarm": { + "name": "pH LOW Alarm", + "value": 0, + "device_type": "alarm" + }, + "orp_high_alarm": { + "name": "ORP HIGH Alarm", + "value": 0, + "device_type": "alarm" + }, + "orp_low_alarm": { + "name": "ORP LOW Alarm", + "value": 0, + "device_type": "alarm" + }, + "ph_supply_alarm": { + "name": "pH Supply Alarm", + "value": 0, + "device_type": "alarm" + }, + "orp_supply_alarm": { + "name": "ORP Supply Alarm", + "value": 0, + "device_type": "alarm" + }, + "probe_fault_alarm": { + "name": "Probe Fault", + "value": 0, + "device_type": "alarm" + } + }, + "alert": { + "flags": 0, + "ph_lockout": { + "name": "pH Lockout", + "value": 0 + }, + "ph_limit": { + "name": "pH Dose Limit Reached", + "value": 0 + }, + "orp_limit": { + "name": "ORP Dose Limit Reached", + "value": 0 + } + }, + "firmware": { + "name": "IntelliChem Firmware", + "value": "1.060" + }, + "water_balance": { + "flags": 0, + "corrosive": { + "name": "SI Corrosive", + "value": 0, + "device_type": "alarm" + }, + "scaling": { + "name": "SI Scaling", + "value": 0, + "device_type": "alarm" + } + }, + "unknown_at_offset_44": 0, + "unknown_at_offset_45": 0, + "unknown_at_offset_46": 0 + }, + "scg": { + "scg_present": 0, + "sensor": { + "state": { + "name": "Chlorinator", + "value": 0 + }, + "salt_ppm": { + "name": "Chlorinator Salt", + "value": 0, + "unit": "ppm", + "state_type": "measurement" + } + }, + "configuration": { + "pool_setpoint": { + "name": "Pool Chlorinator Setpoint", + "value": 51, + "unit": "%", + "min_setpoint": 0, + "max_setpoint": 100, + "step": 5, + "body_type": 0 + }, + "spa_setpoint": { + "name": "Spa Chlorinator Setpoint", + "value": 0, + "unit": "%", + "min_setpoint": 0, + "max_setpoint": 100, + "step": 5, + "body_type": 1 + }, + "super_chlor_timer": { + "name": "Super Chlorination Timer", + "value": 0, + "unit": "hr", + "min_setpoint": 1, + "max_setpoint": 72, + "step": 1 + } + }, + "flags": 0 + } +} diff --git a/tests/components/screenlogic/fixtures/data_min_entity_cleanup.json b/tests/components/screenlogic/fixtures/data_min_entity_cleanup.json new file mode 100644 index 00000000000000..40f7dbe4ad50b5 --- /dev/null +++ b/tests/components/screenlogic/fixtures/data_min_entity_cleanup.json @@ -0,0 +1,38 @@ +{ + "adapter": { + "firmware": { + "name": "Protocol Adapter Firmware", + "value": "POOL: 5.2 Build 736.0 Rel" + } + }, + "controller": { + "controller_id": 100, + "configuration": { + "body_type": { + "0": { "min_setpoint": 40, "max_setpoint": 104 }, + "1": { "min_setpoint": 40, "max_setpoint": 104 } + }, + "is_celsius": { "name": "Is Celsius", "value": 0 }, + "controller_type": 13, + "hardware_type": 0 + }, + "model": { "name": "Model", "value": "EasyTouch2 8" }, + "equipment": { + "flags": 24 + } + }, + "circuit": {}, + "pump": { + "0": { "data": 0 }, + "1": { "data": 0 }, + "2": { "data": 0 }, + "3": { "data": 0 }, + "4": { "data": 0 }, + "5": { "data": 0 }, + "6": { "data": 0 }, + "7": { "data": 0 } + }, + "body": {}, + "intellichem": {}, + "scg": {} +} diff --git a/tests/components/screenlogic/fixtures/data_min_migration.json b/tests/components/screenlogic/fixtures/data_min_migration.json new file mode 100644 index 00000000000000..335c98db0ae7bb --- /dev/null +++ b/tests/components/screenlogic/fixtures/data_min_migration.json @@ -0,0 +1,151 @@ +{ + "adapter": { + "firmware": { + "name": "Protocol Adapter Firmware", + "value": "POOL: 5.2 Build 736.0 Rel" + } + }, + "controller": { + "controller_id": 100, + "configuration": { + "body_type": { + "0": { + "min_setpoint": 40, + "max_setpoint": 104 + }, + "1": { + "min_setpoint": 40, + "max_setpoint": 104 + } + }, + "is_celsius": { + "name": "Is Celsius", + "value": 0 + }, + "controller_type": 13, + "hardware_type": 0 + }, + "model": { + "name": "Model", + "value": "EasyTouch2 8" + }, + "equipment": { + "flags": 32796 + }, + "sensor": { + "active_alert": { + "name": "Active Alert", + "value": 0, + "device_type": "alarm" + } + } + }, + "circuit": {}, + "pump": { + "0": { + "data": 70, + "type": 3, + "state": { + "name": "Pool Low Pump", + "value": 0 + }, + "watts_now": { + "name": "Pool Low Pump Watts Now", + "value": 0, + "unit": "W", + "device_type": "power", + "state_type": "measurement" + }, + "rpm_now": { + "name": "Pool Low Pump RPM Now", + "value": 0, + "unit": "rpm", + "state_type": "measurement" + } + }, + "1": { + "data": 0 + }, + "2": { + "data": 0 + }, + "3": { + "data": 0 + }, + "4": { + "data": 0 + }, + "5": { + "data": 0 + }, + "6": { + "data": 0 + }, + "7": { + "data": 0 + } + }, + "body": {}, + "intellichem": { + "unknown_at_offset_00": 42, + "unknown_at_offset_04": 0, + "sensor": { + "ph_now": { + "name": "pH Now", + "value": 0.0, + "unit": "pH", + "state_type": "measurement" + }, + "orp_now": { + "name": "ORP Now", + "value": 0, + "unit": "mV", + "state_type": "measurement" + } + } + }, + "scg": { + "scg_present": 1, + "sensor": { + "state": { + "name": "Chlorinator", + "value": 0 + }, + "salt_ppm": { + "name": "Chlorinator Salt", + "value": 0, + "unit": "ppm", + "state_type": "measurement" + } + }, + "configuration": { + "pool_setpoint": { + "name": "Pool Chlorinator Setpoint", + "value": 51, + "unit": "%", + "min_setpoint": 0, + "max_setpoint": 100, + "step": 5, + "body_type": 0 + }, + "spa_setpoint": { + "name": "Spa Chlorinator Setpoint", + "value": 0, + "unit": "%", + "min_setpoint": 0, + "max_setpoint": 100, + "step": 5, + "body_type": 1 + }, + "super_chlor_timer": { + "name": "Super Chlorination Timer", + "value": 0, + "unit": "hr", + "min_setpoint": 1, + "max_setpoint": 72, + "step": 1 + } + }, + "flags": 0 + } +} diff --git a/tests/components/screenlogic/snapshots/test_diagnostics.ambr b/tests/components/screenlogic/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000000..05320c147e5a79 --- /dev/null +++ b/tests/components/screenlogic/snapshots/test_diagnostics.ambr @@ -0,0 +1,960 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'config_entry': dict({ + 'data': dict({ + 'ip_address': '127.0.0.1', + 'port': 80, + }), + 'disabled_by': None, + 'domain': 'screenlogic', + 'entry_id': 'screenlogictest', + 'options': dict({ + 'scan_interval': 30, + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'Pentair DD-EE-FF', + 'unique_id': 'aa:bb:cc:dd:ee:ff', + 'version': 1, + }), + 'data': dict({ + 'adapter': dict({ + 'firmware': dict({ + 'name': 'Protocol Adapter Firmware', + 'value': 'POOL: 5.2 Build 736.0 Rel', + }), + }), + 'body': dict({ + '0': dict({ + 'body_type': 0, + 'cool_setpoint': dict({ + 'device_type': 'temperature', + 'name': 'Pool Cool Set Point', + 'unit': '°F', + 'value': 100, + }), + 'heat_mode': dict({ + 'device_type': 'enum', + 'enum_options': list([ + 'Off', + 'Solar', + 'Solar Preferred', + 'Heater', + "Don't Change", + ]), + 'name': 'Pool Heat Mode', + 'value': 0, + }), + 'heat_setpoint': dict({ + 'device_type': 'temperature', + 'name': 'Pool Heat Set Point', + 'unit': '°F', + 'value': 83, + }), + 'heat_state': dict({ + 'device_type': 'enum', + 'enum_options': list([ + 'Off', + 'Solar', + 'Heater', + 'Both', + ]), + 'name': 'Pool Heat', + 'value': 0, + }), + 'last_temperature': dict({ + 'device_type': 'temperature', + 'name': 'Last Pool Temperature', + 'state_type': 'measurement', + 'unit': '°F', + 'value': 81, + }), + 'max_setpoint': 104, + 'min_setpoint': 40, + 'name': 'Pool', + }), + '1': dict({ + 'body_type': 1, + 'cool_setpoint': dict({ + 'device_type': 'temperature', + 'name': 'Spa Cool Set Point', + 'unit': '°F', + 'value': 69, + }), + 'heat_mode': dict({ + 'device_type': 'enum', + 'enum_options': list([ + 'Off', + 'Solar', + 'Solar Preferred', + 'Heater', + "Don't Change", + ]), + 'name': 'Spa Heat Mode', + 'value': 0, + }), + 'heat_setpoint': dict({ + 'device_type': 'temperature', + 'name': 'Spa Heat Set Point', + 'unit': '°F', + 'value': 94, + }), + 'heat_state': dict({ + 'device_type': 'enum', + 'enum_options': list([ + 'Off', + 'Solar', + 'Heater', + 'Both', + ]), + 'name': 'Spa Heat', + 'value': 0, + }), + 'last_temperature': dict({ + 'device_type': 'temperature', + 'name': 'Last Spa Temperature', + 'state_type': 'measurement', + 'unit': '°F', + 'value': 84, + }), + 'max_setpoint': 104, + 'min_setpoint': 40, + 'name': 'Spa', + }), + }), + 'circuit': dict({ + '500': dict({ + 'circuit_id': 500, + 'color': dict({ + 'color_position': 0, + 'color_set': 0, + 'color_stagger': 0, + }), + 'configuration': dict({ + 'default_runtime': 720, + 'delay': 0, + 'flags': 1, + 'name_index': 71, + 'unknown_at_offset_62': 0, + 'unknown_at_offset_63': 0, + }), + 'device_id': 1, + 'function': 1, + 'interface': 1, + 'name': 'Spa', + 'value': 0, + }), + '501': dict({ + 'circuit_id': 501, + 'color': dict({ + 'color_position': 0, + 'color_set': 0, + 'color_stagger': 0, + }), + 'configuration': dict({ + 'default_runtime': 720, + 'delay': 0, + 'flags': 0, + 'name_index': 85, + 'unknown_at_offset_94': 0, + 'unknown_at_offset_95': 0, + }), + 'device_id': 2, + 'function': 0, + 'interface': 2, + 'name': 'Waterfall', + 'value': 0, + }), + '502': dict({ + 'circuit_id': 502, + 'color': dict({ + 'color_position': 0, + 'color_set': 2, + 'color_stagger': 2, + }), + 'configuration': dict({ + 'default_runtime': 720, + 'delay': 0, + 'flags': 0, + 'name_index': 62, + 'unknown_at_offset_126': 0, + 'unknown_at_offset_127': 0, + }), + 'device_id': 3, + 'function': 16, + 'interface': 3, + 'name': 'Pool Light', + 'value': 0, + }), + '503': dict({ + 'circuit_id': 503, + 'color': dict({ + 'color_position': 1, + 'color_set': 6, + 'color_stagger': 10, + }), + 'configuration': dict({ + 'default_runtime': 720, + 'delay': 0, + 'flags': 0, + 'name_index': 73, + 'unknown_at_offset_158': 0, + 'unknown_at_offset_159': 0, + }), + 'device_id': 4, + 'function': 16, + 'interface': 3, + 'name': 'Spa Light', + 'value': 0, + }), + '504': dict({ + 'circuit_id': 504, + 'color': dict({ + 'color_position': 0, + 'color_set': 0, + 'color_stagger': 0, + }), + 'configuration': dict({ + 'default_runtime': 240, + 'delay': 0, + 'flags': 0, + 'name_index': 21, + 'unknown_at_offset_186': 0, + 'unknown_at_offset_187': 0, + }), + 'device_id': 5, + 'function': 5, + 'interface': 0, + 'name': 'Cleaner', + 'value': 0, + }), + '505': dict({ + 'circuit_id': 505, + 'color': dict({ + 'color_position': 0, + 'color_set': 0, + 'color_stagger': 0, + }), + 'configuration': dict({ + 'default_runtime': 720, + 'delay': 0, + 'flags': 1, + 'name_index': 63, + 'unknown_at_offset_214': 0, + 'unknown_at_offset_215': 0, + }), + 'device_id': 6, + 'function': 2, + 'interface': 0, + 'name': 'Pool Low', + 'value': 0, + }), + '506': dict({ + 'circuit_id': 506, + 'color': dict({ + 'color_position': 0, + 'color_set': 0, + 'color_stagger': 0, + }), + 'configuration': dict({ + 'default_runtime': 720, + 'delay': 0, + 'flags': 0, + 'name_index': 91, + 'unknown_at_offset_246': 0, + 'unknown_at_offset_247': 0, + }), + 'device_id': 7, + 'function': 7, + 'interface': 4, + 'name': 'Yard Light', + 'value': 0, + }), + '507': dict({ + 'circuit_id': 507, + 'color': dict({ + 'color_position': 0, + 'color_set': 0, + 'color_stagger': 0, + }), + 'configuration': dict({ + 'default_runtime': 1620, + 'delay': 0, + 'flags': 0, + 'name_index': 101, + 'unknown_at_offset_274': 0, + 'unknown_at_offset_275': 0, + }), + 'device_id': 8, + 'function': 0, + 'interface': 2, + 'name': 'Cameras', + 'value': 1, + }), + '508': dict({ + 'circuit_id': 508, + 'color': dict({ + 'color_position': 0, + 'color_set': 0, + 'color_stagger': 0, + }), + 'configuration': dict({ + 'default_runtime': 720, + 'delay': 0, + 'flags': 0, + 'name_index': 61, + 'unknown_at_offset_306': 0, + 'unknown_at_offset_307': 0, + }), + 'device_id': 9, + 'function': 0, + 'interface': 0, + 'name': 'Pool High', + 'value': 0, + }), + '510': dict({ + 'circuit_id': 510, + 'color': dict({ + 'color_position': 0, + 'color_set': 0, + 'color_stagger': 0, + }), + 'configuration': dict({ + 'default_runtime': 720, + 'delay': 0, + 'flags': 0, + 'name_index': 78, + 'unknown_at_offset_334': 0, + 'unknown_at_offset_335': 0, + }), + 'device_id': 11, + 'function': 14, + 'interface': 1, + 'name': 'Spillway', + 'value': 0, + }), + '511': dict({ + 'circuit_id': 511, + 'color': dict({ + 'color_position': 0, + 'color_set': 0, + 'color_stagger': 0, + }), + 'configuration': dict({ + 'default_runtime': 720, + 'delay': 0, + 'flags': 0, + 'name_index': 61, + 'unknown_at_offset_366': 0, + 'unknown_at_offset_367': 0, + }), + 'device_id': 12, + 'function': 0, + 'interface': 5, + 'name': 'Pool High', + 'value': 0, + }), + }), + 'controller': dict({ + 'configuration': dict({ + 'body_type': dict({ + '0': dict({ + 'max_setpoint': 104, + 'min_setpoint': 40, + }), + '1': dict({ + 'max_setpoint': 104, + 'min_setpoint': 40, + }), + }), + 'circuit_count': 11, + 'color': list([ + dict({ + 'name': 'White', + 'value': list([ + 255, + 255, + 255, + ]), + }), + dict({ + 'name': 'Light Green', + 'value': list([ + 160, + 255, + 160, + ]), + }), + dict({ + 'name': 'Green', + 'value': list([ + 0, + 255, + 80, + ]), + }), + dict({ + 'name': 'Cyan', + 'value': list([ + 0, + 255, + 200, + ]), + }), + dict({ + 'name': 'Blue', + 'value': list([ + 100, + 140, + 255, + ]), + }), + dict({ + 'name': 'Lavender', + 'value': list([ + 230, + 130, + 255, + ]), + }), + dict({ + 'name': 'Magenta', + 'value': list([ + 255, + 0, + 128, + ]), + }), + dict({ + 'name': 'Light Magenta', + 'value': list([ + 255, + 180, + 210, + ]), + }), + ]), + 'color_count': 8, + 'controller_data': 0, + 'controller_type': 13, + 'generic_circuit_name': 'Water Features', + 'hardware_type': 0, + 'interface_tab_flags': 127, + 'is_celsius': dict({ + 'name': 'Is Celsius', + 'value': 0, + }), + 'remotes': 0, + 'show_alarms': 0, + 'unknown_at_offset_09': 0, + 'unknown_at_offset_10': 0, + 'unknown_at_offset_11': 0, + }), + 'controller_id': 100, + 'equipment': dict({ + 'flags': 98360, + 'list': list([ + 'INTELLIBRITE', + 'INTELLIFLO_0', + 'INTELLIFLO_1', + 'INTELLICHEM', + 'HYBRID_HEATER', + ]), + }), + 'model': dict({ + 'name': 'Model', + 'value': 'EasyTouch2 8', + }), + 'sensor': dict({ + 'active_alert': dict({ + 'device_type': 'alarm', + 'name': 'Active Alert', + 'value': 0, + }), + 'air_temperature': dict({ + 'device_type': 'temperature', + 'name': 'Air Temperature', + 'state_type': 'measurement', + 'unit': '°F', + 'value': 69, + }), + 'cleaner_delay': dict({ + 'name': 'Cleaner Delay', + 'value': 0, + }), + 'freeze_mode': dict({ + 'name': 'Freeze Mode', + 'value': 0, + }), + 'orp': dict({ + 'name': 'ORP', + 'state_type': 'measurement', + 'unit': 'mV', + 'value': 728, + }), + 'orp_supply_level': dict({ + 'name': 'ORP Supply Level', + 'state_type': 'measurement', + 'value': 3, + }), + 'ph': dict({ + 'name': 'pH', + 'state_type': 'measurement', + 'unit': 'pH', + 'value': 7.61, + }), + 'ph_supply_level': dict({ + 'name': 'pH Supply Level', + 'state_type': 'measurement', + 'value': 2, + }), + 'pool_delay': dict({ + 'name': 'Pool Delay', + 'value': 0, + }), + 'salt_ppm': dict({ + 'name': 'Salt', + 'state_type': 'measurement', + 'unit': 'ppm', + 'value': 0, + }), + 'saturation': dict({ + 'name': 'Saturation Index', + 'state_type': 'measurement', + 'unit': 'lsi', + 'value': 0.06, + }), + 'spa_delay': dict({ + 'name': 'Spa Delay', + 'value': 0, + }), + 'state': dict({ + 'device_type': 'enum', + 'enum_options': list([ + 'Unknown', + 'Ready', + 'Sync', + 'Service', + ]), + 'name': 'Controller State', + 'value': 1, + }), + }), + }), + 'intellichem': dict({ + 'alarm': dict({ + 'flags': 1, + 'flow_alarm': dict({ + 'device_type': 'alarm', + 'name': 'Flow Alarm', + 'value': 1, + }), + 'orp_high_alarm': dict({ + 'device_type': 'alarm', + 'name': 'ORP HIGH Alarm', + 'value': 0, + }), + 'orp_low_alarm': dict({ + 'device_type': 'alarm', + 'name': 'ORP LOW Alarm', + 'value': 0, + }), + 'orp_supply_alarm': dict({ + 'device_type': 'alarm', + 'name': 'ORP Supply Alarm', + 'value': 0, + }), + 'ph_high_alarm': dict({ + 'device_type': 'alarm', + 'name': 'pH HIGH Alarm', + 'value': 0, + }), + 'ph_low_alarm': dict({ + 'device_type': 'alarm', + 'name': 'pH LOW Alarm', + 'value': 0, + }), + 'ph_supply_alarm': dict({ + 'device_type': 'alarm', + 'name': 'pH Supply Alarm', + 'value': 0, + }), + 'probe_fault_alarm': dict({ + 'device_type': 'alarm', + 'name': 'Probe Fault', + 'value': 0, + }), + }), + 'alert': dict({ + 'flags': 0, + 'orp_limit': dict({ + 'name': 'ORP Dose Limit Reached', + 'value': 0, + }), + 'ph_limit': dict({ + 'name': 'pH Dose Limit Reached', + 'value': 0, + }), + 'ph_lockout': dict({ + 'name': 'pH Lockout', + 'value': 0, + }), + }), + 'configuration': dict({ + 'calcium_harness': dict({ + 'name': 'Calcium Hardness', + 'unit': 'ppm', + 'value': 800, + }), + 'cya': dict({ + 'name': 'Cyanuric Acid', + 'unit': 'ppm', + 'value': 45, + }), + 'flags': 32, + 'orp_setpoint': dict({ + 'name': 'ORP Setpoint', + 'unit': 'mV', + 'value': 720, + }), + 'ph_setpoint': dict({ + 'name': 'pH Setpoint', + 'unit': 'pH', + 'value': 7.6, + }), + 'probe_is_celsius': 0, + 'salt_tds_ppm': dict({ + 'name': 'Salt/TDS', + 'unit': 'ppm', + 'value': 1000, + }), + 'total_alkalinity': dict({ + 'name': 'Total Alkalinity', + 'unit': 'ppm', + 'value': 45, + }), + }), + 'dose_status': dict({ + 'flags': 149, + 'orp_dosing_state': dict({ + 'device_type': 'enum', + 'enum_options': list([ + 'Dosing', + 'Mixing', + 'Monitoring', + ]), + 'name': 'ORP Dosing State', + 'value': 2, + }), + 'orp_last_dose_time': dict({ + 'device_type': 'duration', + 'name': 'Last ORP Dose Time', + 'state_type': 'total_increasing', + 'unit': 'sec', + 'value': 4, + }), + 'orp_last_dose_volume': dict({ + 'device_type': 'volume', + 'name': 'Last ORP Dose Volume', + 'state_type': 'total_increasing', + 'unit': 'mL', + 'value': 8, + }), + 'ph_dosing_state': dict({ + 'device_type': 'enum', + 'enum_options': list([ + 'Dosing', + 'Mixing', + 'Monitoring', + ]), + 'name': 'pH Dosing State', + 'value': 1, + }), + 'ph_last_dose_time': dict({ + 'device_type': 'duration', + 'name': 'Last pH Dose Time', + 'state_type': 'total_increasing', + 'unit': 'sec', + 'value': 5, + }), + 'ph_last_dose_volume': dict({ + 'device_type': 'volume', + 'name': 'Last pH Dose Volume', + 'state_type': 'total_increasing', + 'unit': 'mL', + 'value': 8, + }), + }), + 'firmware': dict({ + 'name': 'IntelliChem Firmware', + 'value': '1.060', + }), + 'sensor': dict({ + 'orp_now': dict({ + 'name': 'ORP Now', + 'state_type': 'measurement', + 'unit': 'mV', + 'value': 0, + }), + 'orp_supply_level': dict({ + 'name': 'ORP Supply Level', + 'state_type': 'measurement', + 'value': 3, + }), + 'ph_now': dict({ + 'name': 'pH Now', + 'state_type': 'measurement', + 'unit': 'pH', + 'value': 0.0, + }), + 'ph_probe_water_temp': dict({ + 'device_type': 'temperature', + 'name': 'pH Probe Water Temperature', + 'state_type': 'measurement', + 'unit': '°F', + 'value': 81, + }), + 'ph_supply_level': dict({ + 'name': 'pH Supply Level', + 'state_type': 'measurement', + 'value': 2, + }), + 'saturation': dict({ + 'name': 'Saturation Index', + 'state_type': 'measurement', + 'unit': 'lsi', + 'value': 0.06, + }), + }), + 'unknown_at_offset_00': 42, + 'unknown_at_offset_04': 0, + 'unknown_at_offset_44': 0, + 'unknown_at_offset_45': 0, + 'unknown_at_offset_46': 0, + 'water_balance': dict({ + 'corrosive': dict({ + 'device_type': 'alarm', + 'name': 'SI Corrosive', + 'value': 0, + }), + 'flags': 0, + 'scaling': dict({ + 'device_type': 'alarm', + 'name': 'SI Scaling', + 'value': 0, + }), + }), + }), + 'pump': dict({ + '0': dict({ + 'data': 70, + 'gpm_now': dict({ + 'name': 'Pool Low Pump GPM Now', + 'state_type': 'measurement', + 'unit': 'gpm', + 'value': 0, + }), + 'preset': dict({ + '0': dict({ + 'device_id': 6, + 'is_rpm': 0, + 'setpoint': 63, + }), + '1': dict({ + 'device_id': 9, + 'is_rpm': 0, + 'setpoint': 72, + }), + '2': dict({ + 'device_id': 1, + 'is_rpm': 1, + 'setpoint': 3450, + }), + '3': dict({ + 'device_id': 130, + 'is_rpm': 0, + 'setpoint': 75, + }), + '4': dict({ + 'device_id': 12, + 'is_rpm': 0, + 'setpoint': 72, + }), + '5': dict({ + 'device_id': 0, + 'is_rpm': 0, + 'setpoint': 30, + }), + '6': dict({ + 'device_id': 0, + 'is_rpm': 0, + 'setpoint': 30, + }), + '7': dict({ + 'device_id': 0, + 'is_rpm': 0, + 'setpoint': 30, + }), + }), + 'rpm_now': dict({ + 'name': 'Pool Low Pump RPM Now', + 'state_type': 'measurement', + 'unit': 'rpm', + 'value': 0, + }), + 'state': dict({ + 'name': 'Pool Low Pump', + 'value': 0, + }), + 'type': 3, + 'unknown_at_offset_16': 0, + 'unknown_at_offset_24': 255, + 'watts_now': dict({ + 'device_type': 'power', + 'name': 'Pool Low Pump Watts Now', + 'state_type': 'measurement', + 'unit': 'W', + 'value': 0, + }), + }), + '1': dict({ + 'data': 66, + 'gpm_now': dict({ + 'name': 'Waterfall Pump GPM Now', + 'state_type': 'measurement', + 'unit': 'gpm', + 'value': 0, + }), + 'preset': dict({ + '0': dict({ + 'device_id': 2, + 'is_rpm': 1, + 'setpoint': 2700, + }), + '1': dict({ + 'device_id': 0, + 'is_rpm': 0, + 'setpoint': 30, + }), + '2': dict({ + 'device_id': 0, + 'is_rpm': 0, + 'setpoint': 30, + }), + '3': dict({ + 'device_id': 0, + 'is_rpm': 0, + 'setpoint': 30, + }), + '4': dict({ + 'device_id': 0, + 'is_rpm': 0, + 'setpoint': 30, + }), + '5': dict({ + 'device_id': 0, + 'is_rpm': 0, + 'setpoint': 30, + }), + '6': dict({ + 'device_id': 0, + 'is_rpm': 0, + 'setpoint': 30, + }), + '7': dict({ + 'device_id': 0, + 'is_rpm': 0, + 'setpoint': 30, + }), + }), + 'rpm_now': dict({ + 'name': 'Waterfall Pump RPM Now', + 'state_type': 'measurement', + 'unit': 'rpm', + 'value': 0, + }), + 'state': dict({ + 'name': 'Waterfall Pump', + 'value': 0, + }), + 'type': 3, + 'unknown_at_offset_16': 0, + 'unknown_at_offset_24': 255, + 'watts_now': dict({ + 'device_type': 'power', + 'name': 'Waterfall Pump Watts Now', + 'state_type': 'measurement', + 'unit': 'W', + 'value': 0, + }), + }), + '2': dict({ + 'data': 0, + }), + '3': dict({ + 'data': 0, + }), + '4': dict({ + 'data': 0, + }), + '5': dict({ + 'data': 0, + }), + '6': dict({ + 'data': 0, + }), + '7': dict({ + 'data': 0, + }), + }), + 'scg': dict({ + 'configuration': dict({ + 'pool_setpoint': dict({ + 'body_type': 0, + 'max_setpoint': 100, + 'min_setpoint': 0, + 'name': 'Pool Chlorinator Setpoint', + 'step': 5, + 'unit': '%', + 'value': 51, + }), + 'spa_setpoint': dict({ + 'body_type': 1, + 'max_setpoint': 100, + 'min_setpoint': 0, + 'name': 'Spa Chlorinator Setpoint', + 'step': 5, + 'unit': '%', + 'value': 0, + }), + 'super_chlor_timer': dict({ + 'max_setpoint': 72, + 'min_setpoint': 1, + 'name': 'Super Chlorination Timer', + 'step': 1, + 'unit': 'hr', + 'value': 0, + }), + }), + 'flags': 0, + 'scg_present': 0, + 'sensor': dict({ + 'salt_ppm': dict({ + 'name': 'Chlorinator Salt', + 'state_type': 'measurement', + 'unit': 'ppm', + 'value': 0, + }), + 'state': dict({ + 'name': 'Chlorinator', + 'value': 0, + }), + }), + }), + }), + 'debug': dict({ + }), + }) +# --- diff --git a/tests/components/screenlogic/test_config_flow.py b/tests/components/screenlogic/test_config_flow.py index f2c39e05b4873d..14488c66564703 100644 --- a/tests/components/screenlogic/test_config_flow.py +++ b/tests/components/screenlogic/test_config_flow.py @@ -2,7 +2,7 @@ from unittest.mock import patch from screenlogicpy import ScreenLogicError -from screenlogicpy.const import ( +from screenlogicpy.const.common import ( SL_GATEWAY_IP, SL_GATEWAY_NAME, SL_GATEWAY_PORT, diff --git a/tests/components/screenlogic/test_data.py b/tests/components/screenlogic/test_data.py new file mode 100644 index 00000000000000..9686dc81586031 --- /dev/null +++ b/tests/components/screenlogic/test_data.py @@ -0,0 +1,91 @@ +"""Tests for ScreenLogic integration data processing.""" +from unittest.mock import DEFAULT, patch + +import pytest +from screenlogicpy import ScreenLogicGateway +from screenlogicpy.const.data import ATTR, DEVICE, GROUP, VALUE + +from homeassistant.components.screenlogic import DOMAIN +from homeassistant.components.screenlogic.data import PathPart, realize_path_template +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from . import ( + DATA_MIN_ENTITY_CLEANUP, + GATEWAY_DISCOVERY_IMPORT_PATH, + MOCK_ADAPTER_MAC, + MOCK_ADAPTER_NAME, + stub_async_connect, +) + +from tests.common import MockConfigEntry + + +async def test_async_cleanup_entries( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test cleanup of unused entities.""" + + mock_config_entry.add_to_hass(hass) + + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + + device: dr.DeviceEntry = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, MOCK_ADAPTER_MAC)}, + ) + + TEST_UNUSED_ENTRY = { + "domain": SENSOR_DOMAIN, + "platform": DOMAIN, + "unique_id": f"{MOCK_ADAPTER_MAC}_saturation", + "suggested_object_id": f"{MOCK_ADAPTER_NAME} Saturation Index", + "disabled_by": None, + "has_entity_name": True, + "original_name": "Saturation Index", + } + + unused_entity: er.RegistryEntry = entity_registry.async_get_or_create( + **TEST_UNUSED_ENTRY, device_id=device.id, config_entry=mock_config_entry + ) + + assert unused_entity + assert unused_entity.unique_id == TEST_UNUSED_ENTRY["unique_id"] + + with patch( + GATEWAY_DISCOVERY_IMPORT_PATH, + return_value={}, + ), patch.multiple( + ScreenLogicGateway, + async_connect=lambda *args, **kwargs: stub_async_connect( + DATA_MIN_ENTITY_CLEANUP, *args, **kwargs + ), + is_connected=True, + _async_connected_request=DEFAULT, + ): + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + deleted_entity = entity_registry.async_get(unused_entity.entity_id) + assert deleted_entity is None + + +def test_realize_path_templates() -> None: + """Test path template realization.""" + assert realize_path_template( + (PathPart.DEVICE, PathPart.INDEX), (DEVICE.PUMP, 0, VALUE.WATTS_NOW) + ) == (DEVICE.PUMP, 0) + + assert realize_path_template( + (PathPart.DEVICE, PathPart.INDEX, PathPart.VALUE, ATTR.NAME_INDEX), + (DEVICE.CIRCUIT, 500, GROUP.CONFIGURATION), + ) == (DEVICE.CIRCUIT, 500, GROUP.CONFIGURATION, ATTR.NAME_INDEX) + + with pytest.raises(KeyError): + realize_path_template( + (PathPart.DEVICE, PathPart.KEY, ATTR.VALUE), + (DEVICE.ADAPTER, VALUE.FIRMWARE), + ) diff --git a/tests/components/screenlogic/test_diagnostics.py b/tests/components/screenlogic/test_diagnostics.py new file mode 100644 index 00000000000000..dcbca954730dd1 --- /dev/null +++ b/tests/components/screenlogic/test_diagnostics.py @@ -0,0 +1,56 @@ +"""Testing for ScreenLogic diagnostics.""" +from unittest.mock import DEFAULT, patch + +from screenlogicpy import ScreenLogicGateway +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import ( + DATA_FULL_CHEM, + GATEWAY_DISCOVERY_IMPORT_PATH, + MOCK_ADAPTER_MAC, + stub_async_connect, +) + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + mock_config_entry.add_to_hass(hass) + + device_registry = dr.async_get(hass) + + device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, MOCK_ADAPTER_MAC)}, + ) + with patch( + GATEWAY_DISCOVERY_IMPORT_PATH, + return_value={}, + ), patch.multiple( + ScreenLogicGateway, + async_connect=lambda *args, **kwargs: stub_async_connect( + DATA_FULL_CHEM, *args, **kwargs + ), + is_connected=True, + _async_connected_request=DEFAULT, + get_debug=lambda self: {}, + ): + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + diag = await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) + + assert diag == snapshot diff --git a/tests/components/screenlogic/test_init.py b/tests/components/screenlogic/test_init.py new file mode 100644 index 00000000000000..3b99354a1df4f0 --- /dev/null +++ b/tests/components/screenlogic/test_init.py @@ -0,0 +1,236 @@ +"""Tests for ScreenLogic integration init.""" +from dataclasses import dataclass +from unittest.mock import DEFAULT, patch + +import pytest +from screenlogicpy import ScreenLogicGateway + +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.screenlogic import DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.util import slugify + +from . import ( + DATA_MIN_MIGRATION, + GATEWAY_DISCOVERY_IMPORT_PATH, + MOCK_ADAPTER_MAC, + MOCK_ADAPTER_NAME, + stub_async_connect, +) + +from tests.common import MockConfigEntry + + +@dataclass +class EntityMigrationData: + """Class to organize minimum entity data.""" + + old_name: str + old_key: str + new_name: str + new_key: str + domain: str + + +TEST_MIGRATING_ENTITIES = [ + EntityMigrationData( + "Chemistry Alarm", + "chem_alarm", + "Active Alert", + "active_alert", + BINARY_SENSOR_DOMAIN, + ), + EntityMigrationData( + "Pool Low Pump Current Watts", + "currentWatts_0", + "Pool Low Pump Watts Now", + "pump_0_watts_now", + SENSOR_DOMAIN, + ), + EntityMigrationData( + "SCG Status", + "scg_status", + "Chlorinator", + "scg_state", + BINARY_SENSOR_DOMAIN, + ), + EntityMigrationData( + "Non-Migrating Sensor", + "nonmigrating", + "Non-Migrating Sensor", + "nonmigrating", + SENSOR_DOMAIN, + ), + EntityMigrationData( + "Cyanuric Acid", + "chem_cya", + "Cyanuric Acid", + "chem_cya", + SENSOR_DOMAIN, + ), + EntityMigrationData( + "Old Sensor", + "old_sensor", + "Old Sensor", + "old_sensor", + SENSOR_DOMAIN, + ), +] + +MIGRATION_CONNECT = lambda *args, **kwargs: stub_async_connect( + DATA_MIN_MIGRATION, *args, **kwargs +) + + +@pytest.mark.parametrize( + ("entity_def", "ent_data"), + [ + ( + { + "domain": ent_data.domain, + "platform": DOMAIN, + "unique_id": f"{MOCK_ADAPTER_MAC}_{ent_data.old_key}", + "suggested_object_id": f"{MOCK_ADAPTER_NAME} {ent_data.old_name}", + "disabled_by": None, + "has_entity_name": True, + "original_name": ent_data.old_name, + }, + ent_data, + ) + for ent_data in TEST_MIGRATING_ENTITIES + ], + ids=[ent_data.old_name for ent_data in TEST_MIGRATING_ENTITIES], +) +async def test_async_migrate_entries( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_def: dict, + ent_data: EntityMigrationData, +) -> None: + """Test migration to new entity names.""" + + mock_config_entry.add_to_hass(hass) + + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + + device: dr.DeviceEntry = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, MOCK_ADAPTER_MAC)}, + ) + + TEST_EXISTING_ENTRY = { + "domain": SENSOR_DOMAIN, + "platform": DOMAIN, + "unique_id": f"{MOCK_ADAPTER_MAC}_cya", + "suggested_object_id": f"{MOCK_ADAPTER_NAME} CYA", + "disabled_by": None, + "has_entity_name": True, + "original_name": "CYA", + } + + entity_registry.async_get_or_create( + **TEST_EXISTING_ENTRY, device_id=device.id, config_entry=mock_config_entry + ) + + entity: er.RegistryEntry = entity_registry.async_get_or_create( + **entity_def, device_id=device.id, config_entry=mock_config_entry + ) + + old_eid = f"{ent_data.domain}.{slugify(f'{MOCK_ADAPTER_NAME} {ent_data.old_name}')}" + old_uid = f"{MOCK_ADAPTER_MAC}_{ent_data.old_key}" + new_eid = f"{ent_data.domain}.{slugify(f'{MOCK_ADAPTER_NAME} {ent_data.new_name}')}" + new_uid = f"{MOCK_ADAPTER_MAC}_{ent_data.new_key}" + + assert entity.unique_id == old_uid + assert entity.entity_id == old_eid + + with patch( + GATEWAY_DISCOVERY_IMPORT_PATH, + return_value={}, + ), patch.multiple( + ScreenLogicGateway, + async_connect=MIGRATION_CONNECT, + is_connected=True, + _async_connected_request=DEFAULT, + ): + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entity_migrated = entity_registry.async_get(new_eid) + assert entity_migrated + assert entity_migrated.entity_id == new_eid + assert entity_migrated.unique_id == new_uid + assert entity_migrated.original_name == ent_data.new_name + + +async def test_entity_migration_data( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test ENTITY_MIGRATION data guards.""" + + mock_config_entry.add_to_hass(hass) + + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + + device: dr.DeviceEntry = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, MOCK_ADAPTER_MAC)}, + ) + + TEST_EXISTING_ENTRY = { + "domain": SENSOR_DOMAIN, + "platform": DOMAIN, + "unique_id": f"{MOCK_ADAPTER_MAC}_missing_device", + "suggested_object_id": f"{MOCK_ADAPTER_NAME} Missing Migration Device", + "disabled_by": None, + "has_entity_name": True, + "original_name": "EMissing Migration Device", + } + + original_entity: er.RegistryEntry = entity_registry.async_get_or_create( + **TEST_EXISTING_ENTRY, device_id=device.id, config_entry=mock_config_entry + ) + + old_eid = original_entity.entity_id + old_uid = original_entity.unique_id + + assert old_uid == f"{MOCK_ADAPTER_MAC}_missing_device" + assert ( + old_eid + == f"{SENSOR_DOMAIN}.{slugify(f'{MOCK_ADAPTER_NAME} Missing Migration Device')}" + ) + + # This patch simulates bad data being added to ENTITY_MIGRATIONS + with patch.dict( + "homeassistant.components.screenlogic.data.ENTITY_MIGRATIONS", + { + "missing_device": { + "new_key": "state", + "old_name": "Missing Migration Device", + "new_name": "Bad ENTITY_MIGRATIONS Entry", + }, + }, + ), patch( + GATEWAY_DISCOVERY_IMPORT_PATH, + return_value={}, + ), patch.multiple( + ScreenLogicGateway, + async_connect=MIGRATION_CONNECT, + is_connected=True, + _async_connected_request=DEFAULT, + ): + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entity_migrated = entity_registry.async_get( + slugify(f"{MOCK_ADAPTER_NAME} Bad ENTITY_MIGRATIONS Entry") + ) + assert entity_migrated is None + + entity_not_migrated = entity_registry.async_get(old_eid) + assert entity_not_migrated == original_entity diff --git a/tests/components/search/test_init.py b/tests/components/search/test_init.py index 40ec9c22afe6b8..ebf70a6239c6c5 100644 --- a/tests/components/search/test_init.py +++ b/tests/components/search/test_init.py @@ -6,7 +6,6 @@ from homeassistant.helpers import ( area_registry as ar, device_registry as dr, - entity, entity_registry as er, ) from homeassistant.setup import async_setup_component @@ -22,11 +21,9 @@ def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: MOCK_ENTITY_SOURCES = { "light.platform_config_source": { - "source": entity.SOURCE_PLATFORM_CONFIG, "domain": "wled", }, "light.config_entry_source": { - "source": entity.SOURCE_CONFIG_ENTRY, "config_entry": "config_entry_id", "domain": "wled", }, @@ -73,11 +70,9 @@ async def test_search( entity_sources = { "light.wled_platform_config_source": { - "source": entity.SOURCE_PLATFORM_CONFIG, "domain": "wled", }, "light.wled_config_entry_source": { - "source": entity.SOURCE_CONFIG_ENTRY, "config_entry": wled_config_entry.entry_id, "domain": "wled", }, diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index 1f836ad909555a..b7682eb2ec2a89 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -164,7 +164,7 @@ async def test_deprecated_last_reset( f"with state_class {state_class} has set last_reset. Setting last_reset for " "entities with state_class other than 'total' is not supported. Please update " "your configuration if state_class is manually configured, otherwise report it " - "to the custom integration author." + "to the custom integration author" ) in caplog.text state = hass.states.get("sensor.test") diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index 797673265a62a1..00f88561880407 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -191,6 +191,7 @@ def mock_light_set_state( MOCK_STATUS_RPC = { "switch:0": {"output": True}, + "input:0": {"id": 0, "state": None}, "light:0": {"output": True, "brightness": 53.0}, "cloud": {"connected": False}, "cover:0": { @@ -202,6 +203,10 @@ def mock_light_set_state( "devicepower:0": {"external": {"present": True}}, "temperature:0": {"tC": 22.9}, "illuminance:0": {"lux": 345}, + "em1:0": {"act_power": 85.3}, + "em1:1": {"act_power": 123.3}, + "em1data:0": {"total_act_energy": 123456.4}, + "em1data:1": {"total_act_energy": 987654.3}, "sys": { "available_updates": { "beta": {"version": "some_beta_version"}, diff --git a/tests/components/shelly/test_config_flow.py b/tests/components/shelly/test_config_flow.py index 7a29d7b1a42b8f..073847e03089ad 100644 --- a/tests/components/shelly/test_config_flow.py +++ b/tests/components/shelly/test_config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations from dataclasses import replace +from ipaddress import ip_address from unittest.mock import AsyncMock, patch from aioshelly.exceptions import ( @@ -29,8 +30,8 @@ from tests.typing import WebSocketGenerator DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( - host="1.1.1.1", - addresses=["1.1.1.1"], + ip_address=ip_address("1.1.1.1"), + ip_addresses=[ip_address("1.1.1.1")], hostname="mock_hostname", name="shelly1pm-12345", port=None, @@ -38,8 +39,8 @@ type="mock_type", ) DISCOVERY_INFO_WITH_MAC = zeroconf.ZeroconfServiceInfo( - host="1.1.1.1", - addresses=["1.1.1.1"], + ip_address=ip_address("1.1.1.1"), + ip_addresses=[ip_address("1.1.1.1")], hostname="mock_hostname", name="shelly1pm-AABBCCDDEEFF", port=None, @@ -651,7 +652,9 @@ async def test_zeroconf_with_wifi_ap_ip(hass: HomeAssistant) -> None: ): result = await hass.config_entries.flow.async_init( DOMAIN, - data=replace(DISCOVERY_INFO, host=config_flow.INTERNAL_WIFI_AP_IP), + data=replace( + DISCOVERY_INFO, ip_address=ip_address(config_flow.INTERNAL_WIFI_AP_IP) + ), context={"source": config_entries.SOURCE_ZEROCONF}, ) assert result["type"] == data_entry_flow.FlowResultType.ABORT diff --git a/tests/components/shelly/test_event.py b/tests/components/shelly/test_event.py new file mode 100644 index 00000000000000..8222e42408bad1 --- /dev/null +++ b/tests/components/shelly/test_event.py @@ -0,0 +1,70 @@ +"""Tests for Shelly button platform.""" +from __future__ import annotations + +from pytest_unordered import unordered + +from homeassistant.components.event import ( + ATTR_EVENT_TYPE, + ATTR_EVENT_TYPES, + DOMAIN as EVENT_DOMAIN, + EventDeviceClass, +) +from homeassistant.const import ATTR_DEVICE_CLASS, STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_registry import async_get + +from . import init_integration, inject_rpc_device_event, register_entity + + +async def test_rpc_button(hass: HomeAssistant, mock_rpc_device, monkeypatch) -> None: + """Test RPC device event.""" + await init_integration(hass, 2) + entity_id = "event.test_name_input_0" + registry = async_get(hass) + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_UNKNOWN + assert state.attributes.get(ATTR_EVENT_TYPES) == unordered( + ["btn_down", "btn_up", "double_push", "long_push", "single_push", "triple_push"] + ) + assert state.attributes.get(ATTR_EVENT_TYPE) is None + assert state.attributes.get(ATTR_DEVICE_CLASS) == EventDeviceClass.BUTTON + + entry = registry.async_get(entity_id) + assert entry + assert entry.unique_id == "123456789ABC-input:0" + + inject_rpc_device_event( + monkeypatch, + mock_rpc_device, + { + "events": [ + { + "event": "single_push", + "id": 0, + "ts": 1668522399.2, + } + ], + "ts": 1668522399.2, + }, + ) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.attributes.get(ATTR_EVENT_TYPE) == "single_push" + + +async def test_rpc_event_removal( + hass: HomeAssistant, mock_rpc_device, monkeypatch +) -> None: + """Test RPC event entity is removed due to removal_condition.""" + registry = async_get(hass) + entity_id = register_entity(hass, EVENT_DOMAIN, "test_name_input_0", "input:0") + + assert registry.async_get(entity_id) is not None + + monkeypatch.setitem(mock_rpc_device.config, "input:0", {"id": 0, "type": "switch"}) + await init_integration(hass, 2) + + assert registry.async_get(entity_id) is None diff --git a/tests/components/shelly/test_sensor.py b/tests/components/shelly/test_sensor.py index 892d06ad626214..a738113f18ff86 100644 --- a/tests/components/shelly/test_sensor.py +++ b/tests/components/shelly/test_sensor.py @@ -408,3 +408,43 @@ async def test_rpc_restored_sleeping_sensor_no_last_state( await hass.async_block_till_done() assert hass.states.get(entity_id).state == "22.9" + + +async def test_rpc_em1_sensors( + hass: HomeAssistant, mock_rpc_device, entity_registry_enabled_by_default: None +) -> None: + """Test RPC sensors for EM1 component.""" + registry = async_get(hass) + await init_integration(hass, 2) + + state = hass.states.get("sensor.test_name_em0_power") + assert state + assert state.state == "85.3" + + entry = registry.async_get("sensor.test_name_em0_power") + assert entry + assert entry.unique_id == "123456789ABC-em1:0-power_em1" + + state = hass.states.get("sensor.test_name_em1_power") + assert state + assert state.state == "123.3" + + entry = registry.async_get("sensor.test_name_em1_power") + assert entry + assert entry.unique_id == "123456789ABC-em1:1-power_em1" + + state = hass.states.get("sensor.test_name_em0_total_active_energy") + assert state + assert state.state == "123.4564" + + entry = registry.async_get("sensor.test_name_em0_total_active_energy") + assert entry + assert entry.unique_id == "123456789ABC-em1data:0-total_act_energy" + + state = hass.states.get("sensor.test_name_em1_total_active_energy") + assert state + assert state.state == "987.6543" + + entry = registry.async_get("sensor.test_name_em1_total_active_energy") + assert entry + assert entry.unique_id == "123456789ABC-em1data:1-total_act_energy" diff --git a/tests/components/shelly/test_update.py b/tests/components/shelly/test_update.py index 1ff2ac99814c24..454afb73ce15bd 100644 --- a/tests/components/shelly/test_update.py +++ b/tests/components/shelly/test_update.py @@ -29,6 +29,7 @@ from . import ( MOCK_MAC, init_integration, + inject_rpc_device_event, mock_rest_update, register_device, register_entity, @@ -222,6 +223,7 @@ async def test_block_update_auth_error( async def test_rpc_update(hass: HomeAssistant, mock_rpc_device, monkeypatch) -> None: """Test RPC device update entity.""" + entity_id = "update.test_name_firmware_update" monkeypatch.setitem(mock_rpc_device.shelly, "ver", "1") monkeypatch.setitem( mock_rpc_device.status["sys"], @@ -232,7 +234,7 @@ async def test_rpc_update(hass: HomeAssistant, mock_rpc_device, monkeypatch) -> ) await init_integration(hass, 2) - state = hass.states.get("update.test_name_firmware_update") + state = hass.states.get(entity_id) assert state.state == STATE_ON assert state.attributes[ATTR_INSTALLED_VERSION] == "1" assert state.attributes[ATTR_LATEST_VERSION] == "2" @@ -243,21 +245,68 @@ async def test_rpc_update(hass: HomeAssistant, mock_rpc_device, monkeypatch) -> await hass.services.async_call( UPDATE_DOMAIN, SERVICE_INSTALL, - {ATTR_ENTITY_ID: "update.test_name_firmware_update"}, + {ATTR_ENTITY_ID: entity_id}, blocking=True, ) + inject_rpc_device_event( + monkeypatch, + mock_rpc_device, + { + "events": [ + { + "event": "ota_begin", + "id": 1, + "ts": 1668522399.2, + } + ], + "ts": 1668522399.2, + }, + ) + assert mock_rpc_device.trigger_ota_update.call_count == 1 - state = hass.states.get("update.test_name_firmware_update") + state = hass.states.get(entity_id) assert state.state == STATE_ON assert state.attributes[ATTR_INSTALLED_VERSION] == "1" assert state.attributes[ATTR_LATEST_VERSION] == "2" - assert state.attributes[ATTR_IN_PROGRESS] is True + assert state.attributes[ATTR_IN_PROGRESS] == 0 + + inject_rpc_device_event( + monkeypatch, + mock_rpc_device, + { + "events": [ + { + "event": "ota_progress", + "id": 1, + "ts": 1668522399.2, + "progress_percent": 50, + } + ], + "ts": 1668522399.2, + }, + ) + + assert hass.states.get(entity_id).attributes[ATTR_IN_PROGRESS] == 50 + inject_rpc_device_event( + monkeypatch, + mock_rpc_device, + { + "events": [ + { + "event": "ota_success", + "id": 1, + "ts": 1668522399.2, + } + ], + "ts": 1668522399.2, + }, + ) monkeypatch.setitem(mock_rpc_device.shelly, "ver", "2") mock_rpc_device.mock_update() - state = hass.states.get("update.test_name_firmware_update") + state = hass.states.get(entity_id) assert state.state == STATE_OFF assert state.attributes[ATTR_INSTALLED_VERSION] == "2" assert state.attributes[ATTR_LATEST_VERSION] == "2" @@ -401,6 +450,7 @@ async def test_rpc_beta_update( suggested_object_id="test_name_beta_firmware_update", disabled_by=None, ) + entity_id = "update.test_name_beta_firmware_update" monkeypatch.setitem(mock_rpc_device.shelly, "ver", "1") monkeypatch.setitem( mock_rpc_device.status["sys"], @@ -412,7 +462,7 @@ async def test_rpc_beta_update( ) await init_integration(hass, 2) - state = hass.states.get("update.test_name_beta_firmware_update") + state = hass.states.get(entity_id) assert state.state == STATE_OFF assert state.attributes[ATTR_INSTALLED_VERSION] == "1" assert state.attributes[ATTR_LATEST_VERSION] == "1" @@ -428,7 +478,7 @@ async def test_rpc_beta_update( ) await mock_rest_update(hass, freezer) - state = hass.states.get("update.test_name_beta_firmware_update") + state = hass.states.get(entity_id) assert state.state == STATE_ON assert state.attributes[ATTR_INSTALLED_VERSION] == "1" assert state.attributes[ATTR_LATEST_VERSION] == "2b" @@ -437,21 +487,68 @@ async def test_rpc_beta_update( await hass.services.async_call( UPDATE_DOMAIN, SERVICE_INSTALL, - {ATTR_ENTITY_ID: "update.test_name_beta_firmware_update"}, + {ATTR_ENTITY_ID: entity_id}, blocking=True, ) + inject_rpc_device_event( + monkeypatch, + mock_rpc_device, + { + "events": [ + { + "event": "ota_begin", + "id": 1, + "ts": 1668522399.2, + } + ], + "ts": 1668522399.2, + }, + ) + assert mock_rpc_device.trigger_ota_update.call_count == 1 - state = hass.states.get("update.test_name_beta_firmware_update") + state = hass.states.get(entity_id) assert state.state == STATE_ON assert state.attributes[ATTR_INSTALLED_VERSION] == "1" assert state.attributes[ATTR_LATEST_VERSION] == "2b" - assert state.attributes[ATTR_IN_PROGRESS] is True + assert state.attributes[ATTR_IN_PROGRESS] == 0 + inject_rpc_device_event( + monkeypatch, + mock_rpc_device, + { + "events": [ + { + "event": "ota_progress", + "id": 1, + "ts": 1668522399.2, + "progress_percent": 40, + } + ], + "ts": 1668522399.2, + }, + ) + + assert hass.states.get(entity_id).attributes[ATTR_IN_PROGRESS] == 40 + + inject_rpc_device_event( + monkeypatch, + mock_rpc_device, + { + "events": [ + { + "event": "ota_success", + "id": 1, + "ts": 1668522399.2, + } + ], + "ts": 1668522399.2, + }, + ) monkeypatch.setitem(mock_rpc_device.shelly, "ver", "2b") await mock_rest_update(hass, freezer) - state = hass.states.get("update.test_name_beta_firmware_update") + state = hass.states.get(entity_id) assert state.state == STATE_OFF assert state.attributes[ATTR_INSTALLED_VERSION] == "2b" assert state.attributes[ATTR_LATEST_VERSION] == "2b" diff --git a/tests/components/shelly/test_utils.py b/tests/components/shelly/test_utils.py index 1bf660deb2a4c5..a163519c9d11ab 100644 --- a/tests/components/shelly/test_utils.py +++ b/tests/components/shelly/test_utils.py @@ -8,6 +8,7 @@ get_device_uptime, get_number_of_channels, get_rpc_channel_name, + get_rpc_input_name, get_rpc_input_triggers, is_block_momentary_input, ) @@ -210,6 +211,18 @@ async def test_get_rpc_channel_name(mock_rpc_device) -> None: assert get_rpc_channel_name(mock_rpc_device, "input:3") == "Test name switch_3" +async def test_get_rpc_input_name(mock_rpc_device, monkeypatch) -> None: + """Test get RPC input name.""" + assert get_rpc_input_name(mock_rpc_device, "input:0") == "Test name Input 0" + + monkeypatch.setitem( + mock_rpc_device.config, + "input:0", + {"id": 0, "type": "button", "name": "Input name"}, + ) + assert get_rpc_input_name(mock_rpc_device, "input:0") == "Test name Input name" + + async def test_get_rpc_input_triggers(mock_rpc_device, monkeypatch) -> None: """Test get RPC input triggers.""" monkeypatch.setattr(mock_rpc_device, "config", {"input:0": {"type": "button"}}) diff --git a/tests/components/slack/test_notify.py b/tests/components/slack/test_notify.py index 232f78e97e4386..6c90ad8cd392b2 100644 --- a/tests/components/slack/test_notify.py +++ b/tests/components/slack/test_notify.py @@ -6,6 +6,7 @@ from homeassistant.components import notify from homeassistant.components.slack import DOMAIN from homeassistant.components.slack.notify import ( + ATTR_THREAD_TS, CONF_DEFAULT_CHANNEL, SlackNotificationService, ) @@ -93,3 +94,18 @@ async def test_message_icon_url_overrides_default() -> None: mock_fn.assert_called_once() _, kwargs = mock_fn.call_args assert kwargs["icon_url"] == expected_icon + + +async def test_message_as_reply() -> None: + """Tests that a message pointer will be passed to Slack if specified.""" + mock_client = Mock() + mock_client.chat_postMessage = AsyncMock() + service = SlackNotificationService(None, mock_client, CONF_DATA) + + expected_ts = "1624146685.064129" + await service.async_send_message("test", data={ATTR_THREAD_TS: expected_ts}) + + mock_fn = mock_client.chat_postMessage + mock_fn.assert_called_once() + _, kwargs = mock_fn.call_args + assert kwargs["thread_ts"] == expected_ts diff --git a/tests/components/smappee/test_config_flow.py b/tests/components/smappee/test_config_flow.py index a6e8f8ae45c6df..f6f5ab66708040 100644 --- a/tests/components/smappee/test_config_flow.py +++ b/tests/components/smappee/test_config_flow.py @@ -1,5 +1,6 @@ """Test the Smappee component config flow module.""" from http import HTTPStatus +from ipaddress import ip_address from unittest.mock import patch from homeassistant import data_entry_flow, setup @@ -59,8 +60,8 @@ async def test_show_zeroconf_connection_error_form(hass: HomeAssistant) -> None: DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="1.2.3.4", - addresses=["1.2.3.4"], + ip_address=ip_address("1.2.3.4"), + ip_addresses=[ip_address("1.2.3.4")], port=22, hostname="Smappee1006000212.local.", type="_ssh._tcp.local.", @@ -91,8 +92,8 @@ async def test_show_zeroconf_connection_error_form_next_generation( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="1.2.3.4", - addresses=["1.2.3.4"], + ip_address=ip_address("1.2.3.4"), + ip_addresses=[ip_address("1.2.3.4")], port=22, hostname="Smappee5001000212.local.", type="_ssh._tcp.local.", @@ -174,8 +175,8 @@ async def test_zeroconf_wrong_mdns(hass: HomeAssistant) -> None: DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="1.2.3.4", - addresses=["1.2.3.4"], + ip_address=ip_address("1.2.3.4"), + ip_addresses=[ip_address("1.2.3.4")], port=22, hostname="example.local.", type="_ssh._tcp.local.", @@ -285,8 +286,8 @@ async def test_zeroconf_device_exists_abort(hass: HomeAssistant) -> None: DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="1.2.3.4", - addresses=["1.2.3.4"], + ip_address=ip_address("1.2.3.4"), + ip_addresses=[ip_address("1.2.3.4")], port=22, hostname="Smappee1006000212.local.", type="_ssh._tcp.local.", @@ -335,8 +336,8 @@ async def test_zeroconf_abort_if_cloud_device_exists(hass: HomeAssistant) -> Non DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="1.2.3.4", - addresses=["1.2.3.4"], + ip_address=ip_address("1.2.3.4"), + ip_addresses=[ip_address("1.2.3.4")], port=22, hostname="Smappee1006000212.local.", type="_ssh._tcp.local.", @@ -357,8 +358,8 @@ async def test_zeroconf_confirm_abort_if_cloud_device_exists( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="1.2.3.4", - addresses=["1.2.3.4"], + ip_address=ip_address("1.2.3.4"), + ip_addresses=[ip_address("1.2.3.4")], port=22, hostname="Smappee1006000212.local.", type="_ssh._tcp.local.", @@ -480,8 +481,8 @@ async def test_full_zeroconf_flow(hass: HomeAssistant) -> None: DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="1.2.3.4", - addresses=["1.2.3.4"], + ip_address=ip_address("1.2.3.4"), + ip_addresses=[ip_address("1.2.3.4")], port=22, hostname="Smappee1006000212.local.", type="_ssh._tcp.local.", @@ -559,8 +560,8 @@ async def test_full_zeroconf_flow_next_generation(hass: HomeAssistant) -> None: DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="1.2.3.4", - addresses=["1.2.3.4"], + ip_address=ip_address("1.2.3.4"), + ip_addresses=[ip_address("1.2.3.4")], port=22, hostname="Smappee5001000212.local.", type="_ssh._tcp.local.", diff --git a/tests/components/smtp/test_notify.py b/tests/components/smtp/test_notify.py index aca30c8eac7a26..86a21c754ed3f0 100644 --- a/tests/components/smtp/test_notify.py +++ b/tests/components/smtp/test_notify.py @@ -6,7 +6,7 @@ from homeassistant import config as hass_config import homeassistant.components.notify as notify -from homeassistant.components.smtp import DOMAIN +from homeassistant.components.smtp.const import DOMAIN from homeassistant.components.smtp.notify import MailNotificationService from homeassistant.const import SERVICE_RELOAD from homeassistant.core import HomeAssistant diff --git a/tests/components/sonos/conftest.py b/tests/components/sonos/conftest.py index bab2b89009f575..cb912af1cf6799 100644 --- a/tests/components/sonos/conftest.py +++ b/tests/components/sonos/conftest.py @@ -1,5 +1,6 @@ """Configuration for Sonos tests.""" from copy import copy +from ipaddress import ip_address from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest @@ -69,8 +70,8 @@ def increment_variable(self, var_name): def zeroconf_payload(): """Return a default zeroconf payload.""" return zeroconf.ZeroconfServiceInfo( - host="192.168.4.2", - addresses=["192.168.4.2"], + ip_address=ip_address("192.168.4.2"), + ip_addresses=[ip_address("192.168.4.2")], hostname="Sonos-aaa", name="Sonos-aaa@Living Room._sonos._tcp.local.", port=None, diff --git a/tests/components/sonos/test_config_flow.py b/tests/components/sonos/test_config_flow.py index 270bdec4b52a84..2fd8ad110dfd08 100644 --- a/tests/components/sonos/test_config_flow.py +++ b/tests/components/sonos/test_config_flow.py @@ -1,6 +1,7 @@ """Test the sonos config flow.""" from __future__ import annotations +from ipaddress import ip_address from unittest.mock import MagicMock, patch from homeassistant import config_entries @@ -162,8 +163,8 @@ async def test_zeroconf_sonos_v1(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.107", - addresses=["192.168.1.107"], + ip_address=ip_address("192.168.1.107"), + ip_addresses=[ip_address("192.168.1.107")], port=1443, hostname="sonos5CAAFDE47AC8.local.", type="_sonos._tcp.local.", diff --git a/tests/components/soundtouch/test_config_flow.py b/tests/components/soundtouch/test_config_flow.py index 68f884ca0069c6..896202355ac1b8 100644 --- a/tests/components/soundtouch/test_config_flow.py +++ b/tests/components/soundtouch/test_config_flow.py @@ -1,4 +1,5 @@ """Test config flow.""" +from ipaddress import ip_address from unittest.mock import patch from requests import RequestException @@ -75,8 +76,8 @@ async def test_zeroconf_flow_create_entry( DOMAIN, context={CONF_SOURCE: SOURCE_ZEROCONF}, data=ZeroconfServiceInfo( - host=DEVICE_1_IP, - addresses=[DEVICE_1_IP], + ip_address=ip_address(DEVICE_1_IP), + ip_addresses=[ip_address(DEVICE_1_IP)], port=8090, hostname="Bose-SM2-060000000001.local.", type="_soundtouch._tcp.local.", diff --git a/tests/components/speedtestdotnet/conftest.py b/tests/components/speedtestdotnet/conftest.py index 3324b92d8bdbce..0dab08eddef1e6 100644 --- a/tests/components/speedtestdotnet/conftest.py +++ b/tests/components/speedtestdotnet/conftest.py @@ -3,14 +3,12 @@ import pytest -from . import MOCK_RESULTS, MOCK_SERVERS +from . import MOCK_SERVERS -@pytest.fixture(autouse=True) +@pytest.fixture def mock_api(): """Mock entry setup.""" with patch("speedtest.Speedtest") as mock_api: mock_api.return_value.get_servers.return_value = MOCK_SERVERS - mock_api.return_value.get_best_server.return_value = MOCK_SERVERS[1][0] - mock_api.return_value.results.dict.return_value = MOCK_RESULTS yield mock_api diff --git a/tests/components/speedtestdotnet/test_init.py b/tests/components/speedtestdotnet/test_init.py index da19fd85dd35f4..5083f56a8e2ab0 100644 --- a/tests/components/speedtestdotnet/test_init.py +++ b/tests/components/speedtestdotnet/test_init.py @@ -18,25 +18,6 @@ from tests.common import MockConfigEntry, async_fire_time_changed -async def test_successful_config_entry(hass: HomeAssistant) -> None: - """Test that SpeedTestDotNet is configured successfully.""" - - entry = MockConfigEntry( - domain=DOMAIN, - data={}, - options={ - CONF_SERVER_NAME: "Country1 - Sponsor1 - Server1", - CONF_SERVER_ID: "1", - }, - ) - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - - assert entry.state == ConfigEntryState.LOADED - assert hass.data[DOMAIN] - - async def test_setup_failed(hass: HomeAssistant, mock_api: MagicMock) -> None: """Test SpeedTestDotNet failed due to an error.""" @@ -50,16 +31,24 @@ async def test_setup_failed(hass: HomeAssistant, mock_api: MagicMock) -> None: assert entry.state is ConfigEntryState.SETUP_RETRY -async def test_unload_entry(hass: HomeAssistant) -> None: - """Test removing SpeedTestDotNet.""" +async def test_entry_lifecycle(hass: HomeAssistant, mock_api: MagicMock) -> None: + """Test the SpeedTestDotNet entry lifecycle.""" entry = MockConfigEntry( domain=DOMAIN, + data={}, + options={ + CONF_SERVER_NAME: "Country1 - Sponsor1 - Server1", + CONF_SERVER_ID: "1", + }, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() + assert entry.state == ConfigEntryState.LOADED + assert hass.data[DOMAIN] + assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/speedtestdotnet/test_sensor.py b/tests/components/speedtestdotnet/test_sensor.py index 887f0ba0491225..d15e9fb92f4bfb 100644 --- a/tests/components/speedtestdotnet/test_sensor.py +++ b/tests/components/speedtestdotnet/test_sensor.py @@ -3,11 +3,11 @@ from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.speedtestdotnet import DOMAIN -from homeassistant.core import HomeAssistant, State +from homeassistant.core import HomeAssistant from . import MOCK_RESULTS, MOCK_SERVERS, MOCK_STATES -from tests.common import MockConfigEntry, mock_restore_cache +from tests.common import MockConfigEntry async def test_speedtestdotnet_sensors( @@ -36,33 +36,3 @@ async def test_speedtestdotnet_sensors( sensor = hass.states.get("sensor.speedtest_ping") assert sensor assert sensor.state == MOCK_STATES["ping"] - - -async def test_restore_last_state(hass: HomeAssistant, mock_api: MagicMock) -> None: - """Test restoring last state for sensors.""" - mock_restore_cache( - hass, - [ - State(f"sensor.speedtest_{sensor}", state) - for sensor, state in MOCK_STATES.items() - ], - ) - entry = MockConfigEntry(domain=DOMAIN) - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 3 - - sensor = hass.states.get("sensor.speedtest_ping") - assert sensor - assert sensor.state == MOCK_STATES["ping"] - - sensor = hass.states.get("sensor.speedtest_download") - assert sensor - assert sensor.state == MOCK_STATES["download"] - - sensor = hass.states.get("sensor.speedtest_ping") - assert sensor - assert sensor.state == MOCK_STATES["ping"] diff --git a/tests/components/spotify/test_config_flow.py b/tests/components/spotify/test_config_flow.py index 46d9741684ae61..7940964d68f94f 100644 --- a/tests/components/spotify/test_config_flow.py +++ b/tests/components/spotify/test_config_flow.py @@ -1,5 +1,6 @@ """Tests for the Spotify config flow.""" from http import HTTPStatus +from ipaddress import ip_address from unittest.mock import patch import pytest @@ -22,8 +23,8 @@ from tests.typing import ClientSessionGenerator BLANK_ZEROCONF_INFO = zeroconf.ZeroconfServiceInfo( - host="1.2.3.4", - addresses=["1.2.3.4"], + ip_address=ip_address("1.2.3.4"), + ip_addresses=[ip_address("1.2.3.4")], hostname="mock_hostname", name="mock_name", port=None, diff --git a/tests/components/sql/test_sensor.py b/tests/components/sql/test_sensor.py index 3d0e2768adea66..cb988d3f2d47c1 100644 --- a/tests/components/sql/test_sensor.py +++ b/tests/components/sql/test_sensor.py @@ -502,6 +502,9 @@ async def test_multiple_sensors_using_same_db( assert state.state == "5" assert state.attributes["value"] == 5 + with patch("sqlalchemy.engine.base.Engine.dispose"): + await hass.async_stop() + async def test_engine_is_disposed_at_stop( recorder_mock: Recorder, hass: HomeAssistant diff --git a/tests/components/ssdp/test_init.py b/tests/components/ssdp/test_init.py index ed5241a42ad26e..324136c011b222 100644 --- a/tests/components/ssdp/test_init.py +++ b/tests/components/ssdp/test_init.py @@ -1,5 +1,5 @@ """Test the SSDP integration.""" -from datetime import datetime, timedelta +from datetime import datetime from ipaddress import IPv4Address from unittest.mock import ANY, AsyncMock, patch @@ -447,7 +447,7 @@ async def test_start_stop_scanner(mock_source_set, hass: HomeAssistant) -> None: hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=200)) + async_fire_time_changed(hass, dt_util.utcnow() + ssdp.SCAN_INTERVAL) await hass.async_block_till_done() assert ssdp_listener.async_start.call_count == 1 assert ssdp_listener.async_search.call_count == 4 @@ -455,7 +455,7 @@ async def test_start_stop_scanner(mock_source_set, hass: HomeAssistant) -> None: hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) await hass.async_block_till_done() - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=200)) + async_fire_time_changed(hass, dt_util.utcnow() + ssdp.SCAN_INTERVAL) await hass.async_block_till_done() assert ssdp_listener.async_start.call_count == 1 assert ssdp_listener.async_search.call_count == 4 @@ -785,7 +785,7 @@ async def test_ipv4_does_additional_search_for_sonos( hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=200)) + async_fire_time_changed(hass, dt_util.utcnow() + ssdp.SCAN_INTERVAL) await hass.async_block_till_done() assert ssdp_listener.async_search.call_count == 6 diff --git a/tests/components/stream/test_init.py b/tests/components/stream/test_init.py index 0c625a8dec167c..525eb9d859d59b 100644 --- a/tests/components/stream/test_init.py +++ b/tests/components/stream/test_init.py @@ -4,6 +4,7 @@ import av import pytest +from homeassistant.components.logger import EVENT_LOGGING_CHANGED from homeassistant.components.stream import __name__ as stream_name from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -14,8 +15,6 @@ async def test_log_levels( ) -> None: """Test that the worker logs the url without username and password.""" - logging.getLogger(stream_name).setLevel(logging.INFO) - await async_setup_component(hass, "stream", {"stream": {}}) # These namespaces should only pass log messages when the stream logger @@ -31,11 +30,17 @@ async def test_log_levels( "NULL", ) + logging.getLogger(stream_name).setLevel(logging.INFO) + hass.bus.async_fire(EVENT_LOGGING_CHANGED) + await hass.async_block_till_done() + # Since logging is at INFO, these should not pass for namespace in namespaces_to_toggle: av.logging.log(av.logging.ERROR, namespace, "SHOULD NOT PASS") logging.getLogger(stream_name).setLevel(logging.DEBUG) + hass.bus.async_fire(EVENT_LOGGING_CHANGED) + await hass.async_block_till_done() # Since logging is now at DEBUG, these should now pass for namespace in namespaces_to_toggle: diff --git a/tests/components/switchbot_cloud/__init__.py b/tests/components/switchbot_cloud/__init__.py new file mode 100644 index 00000000000000..72d23c837ac7c6 --- /dev/null +++ b/tests/components/switchbot_cloud/__init__.py @@ -0,0 +1,20 @@ +"""Tests for the SwitchBot Cloud integration.""" +from homeassistant.components.switchbot_cloud.const import DOMAIN +from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +def configure_integration(hass: HomeAssistant) -> MockConfigEntry: + """Configure the integration.""" + config = { + CONF_API_TOKEN: "test-token", + CONF_API_KEY: "test-api-key", + } + entry = MockConfigEntry( + domain=DOMAIN, data=config, entry_id="123456", unique_id="123456" + ) + entry.add_to_hass(hass) + + return entry diff --git a/tests/components/switchbot_cloud/conftest.py b/tests/components/switchbot_cloud/conftest.py new file mode 100644 index 00000000000000..b96d76387975ad --- /dev/null +++ b/tests/components/switchbot_cloud/conftest.py @@ -0,0 +1,15 @@ +"""Common fixtures for the SwitchBot via API tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.switchbot_cloud.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/switchbot_cloud/test_config_flow.py b/tests/components/switchbot_cloud/test_config_flow.py new file mode 100644 index 00000000000000..6fdf8fecdb7119 --- /dev/null +++ b/tests/components/switchbot_cloud/test_config_flow.py @@ -0,0 +1,90 @@ +"""Test the SwitchBot via API config flow.""" +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant import config_entries +from homeassistant.components.switchbot_cloud.config_flow import ( + CannotConnect, + InvalidAuth, +) +from homeassistant.components.switchbot_cloud.const import DOMAIN, ENTRY_TITLE +from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + + +async def _fill_out_form_and_assert_entry_created( + hass: HomeAssistant, flow_id: str, mock_setup_entry: AsyncMock +) -> None: + """Util function to fill out a form and assert that a config entry is created.""" + with patch( + "homeassistant.components.switchbot_cloud.config_flow.SwitchBotAPI.list_devices", + return_value=[], + ): + result_configure = await hass.config_entries.flow.async_configure( + flow_id, + { + CONF_API_TOKEN: "test-token", + CONF_API_KEY: "test-secret-key", + }, + ) + await hass.async_block_till_done() + + assert result_configure["type"] == FlowResultType.CREATE_ENTRY + assert result_configure["title"] == ENTRY_TITLE + assert result_configure["data"] == { + CONF_API_TOKEN: "test-token", + CONF_API_KEY: "test-secret-key", + } + mock_setup_entry.assert_called_once() + + +async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test we get the form.""" + result_init = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result_init["type"] == FlowResultType.FORM + assert not result_init["errors"] + + await _fill_out_form_and_assert_entry_created( + hass, result_init["flow_id"], mock_setup_entry + ) + + +@pytest.mark.parametrize( + ("error", "message"), + [ + (InvalidAuth, "invalid_auth"), + (CannotConnect, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_form_fails( + hass: HomeAssistant, error: Exception, message: str, mock_setup_entry: AsyncMock +) -> None: + """Test we handle error cases.""" + result_init = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.switchbot_cloud.config_flow.SwitchBotAPI.list_devices", + side_effect=error, + ): + result_configure = await hass.config_entries.flow.async_configure( + result_init["flow_id"], + { + CONF_API_TOKEN: "test-token", + CONF_API_KEY: "test-secret-key", + }, + ) + + assert result_configure["type"] == FlowResultType.FORM + assert result_configure["errors"] == {"base": message} + await hass.async_block_till_done() + + await _fill_out_form_and_assert_entry_created( + hass, result_init["flow_id"], mock_setup_entry + ) diff --git a/tests/components/switchbot_cloud/test_init.py b/tests/components/switchbot_cloud/test_init.py new file mode 100644 index 00000000000000..48f0021bdb46d2 --- /dev/null +++ b/tests/components/switchbot_cloud/test_init.py @@ -0,0 +1,100 @@ +"""Tests for the SwitchBot Cloud integration init.""" + +from unittest.mock import patch + +import pytest +from switchbot_api import CannotConnect, Device, InvalidAuth, PowerState + +from homeassistant.components.switchbot_cloud import SwitchBotAPI +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import EVENT_HOMEASSISTANT_START +from homeassistant.core import HomeAssistant + +from . import configure_integration + + +@pytest.fixture +def mock_list_devices(): + """Mock list_devices.""" + with patch.object(SwitchBotAPI, "list_devices") as mock_list_devices: + yield mock_list_devices + + +@pytest.fixture +def mock_get_status(): + """Mock get_status.""" + with patch.object(SwitchBotAPI, "get_status") as mock_get_status: + yield mock_get_status + + +async def test_setup_entry_success( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test successful setup of entry.""" + mock_list_devices.return_value = [ + Device( + deviceId="test-id", + deviceName="test-name", + deviceType="Plug", + hubDeviceId="test-hub-id", + ) + ] + mock_get_status.return_value = {"power": PowerState.ON.value} + entry = configure_integration(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert entry.state == ConfigEntryState.LOADED + + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + mock_list_devices.assert_called_once() + mock_get_status.assert_called() + + +@pytest.mark.parametrize( + ("error", "state"), + [ + (InvalidAuth, ConfigEntryState.SETUP_ERROR), + (CannotConnect, ConfigEntryState.SETUP_RETRY), + ], +) +async def test_setup_entry_fails_when_listing_devices( + hass: HomeAssistant, + error: Exception, + state: ConfigEntryState, + mock_list_devices, + mock_get_status, +) -> None: + """Test error handling when list_devices in setup of entry.""" + mock_list_devices.side_effect = error + entry = configure_integration(hass) + await hass.config_entries.async_setup(entry.entry_id) + assert entry.state == state + + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + mock_list_devices.assert_called_once() + mock_get_status.assert_not_called() + + +async def test_setup_entry_fails_when_refreshing( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test error handling in get_status in setup of entry.""" + mock_list_devices.return_value = [ + Device( + deviceId="test-id", + deviceName="test-name", + deviceType="Plug", + hubDeviceId="test-hub-id", + ) + ] + mock_get_status.side_effect = CannotConnect + entry = configure_integration(hass) + await hass.config_entries.async_setup(entry.entry_id) + assert entry.state == ConfigEntryState.LOADED + + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + mock_list_devices.assert_called_once() + mock_get_status.assert_called() diff --git a/tests/components/synology_dsm/test_config_flow.py b/tests/components/synology_dsm/test_config_flow.py index ef4dee7c597f52..4d4ba583169871 100644 --- a/tests/components/synology_dsm/test_config_flow.py +++ b/tests/components/synology_dsm/test_config_flow.py @@ -1,4 +1,5 @@ """Tests for the Synology DSM config flow.""" +from ipaddress import ip_address from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest @@ -666,8 +667,8 @@ async def test_discovered_via_zeroconf(hass: HomeAssistant, service: MagicMock) DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.5", - addresses=["192.168.1.5"], + ip_address=ip_address("192.168.1.5"), + ip_addresses=[ip_address("192.168.1.5")], port=5000, hostname="mydsm.local.", type="_http._tcp.local.", @@ -714,8 +715,8 @@ async def test_discovered_via_zeroconf_missing_mac( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.5", - addresses=["192.168.1.5"], + ip_address=ip_address("192.168.1.5"), + ip_addresses=[ip_address("192.168.1.5")], port=5000, hostname="mydsm.local.", type="_http._tcp.local.", diff --git a/tests/components/system_bridge/test_config_flow.py b/tests/components/system_bridge/test_config_flow.py index d01ed9a3ff8f44..56afc87c3bba39 100644 --- a/tests/components/system_bridge/test_config_flow.py +++ b/tests/components/system_bridge/test_config_flow.py @@ -1,5 +1,6 @@ """Test the System Bridge config flow.""" import asyncio +from ipaddress import ip_address from unittest.mock import patch from systembridgeconnector.const import MODEL_SYSTEM, TYPE_DATA_UPDATE @@ -37,8 +38,8 @@ } FIXTURE_ZEROCONF = zeroconf.ZeroconfServiceInfo( - host="test-bridge", - addresses=["1.1.1.1"], + ip_address=ip_address("1.1.1.1"), + ip_addresses=[ip_address("1.1.1.1")], port=9170, hostname="test-bridge.local.", type="_system-bridge._tcp.local.", @@ -55,8 +56,8 @@ ) FIXTURE_ZEROCONF_BAD = zeroconf.ZeroconfServiceInfo( - host="1.1.1.1", - addresses=["1.1.1.1"], + ip_address=ip_address("1.1.1.1"), + ip_addresses=[ip_address("1.1.1.1")], port=9170, hostname="test-bridge.local.", type="_system-bridge._tcp.local.", diff --git a/tests/components/system_log/test_init.py b/tests/components/system_log/test_init.py index bd861ac7668325..1357d9e5e9e081 100644 --- a/tests/components/system_log/test_init.py +++ b/tests/components/system_log/test_init.py @@ -4,6 +4,7 @@ import asyncio from collections.abc import Awaitable import logging +import re import traceback from typing import Any from unittest.mock import MagicMock, patch @@ -87,11 +88,6 @@ def handle(self, record: logging.LogRecord) -> None: self.watch_event.set() -def get_frame(name): - """Get log stack frame.""" - return (name, 5, None, None) - - async def async_setup_system_log(hass, config) -> WatchLogErrorHandler: """Set up the system_log component.""" WatchLogErrorHandler.instances = [] @@ -362,21 +358,28 @@ async def test_unknown_path( assert log["source"] == ["unknown_path", 0] +def get_frame(path: str, previous_frame: MagicMock | None) -> MagicMock: + """Get log stack frame.""" + return MagicMock( + f_back=previous_frame, + f_code=MagicMock(co_filename=path), + f_lineno=5, + ) + + async def async_log_error_from_test_path(hass, path, watcher): """Log error while mocking the path.""" call_path = "internal_path.py" + main_frame = get_frame("main_path/main.py", None) + path_frame = get_frame(path, main_frame) + call_path_frame = get_frame(call_path, path_frame) + logger_frame = get_frame("venv_path/logging/log.py", call_path_frame) + with patch.object( _LOGGER, "findCaller", MagicMock(return_value=(call_path, 0, None, None)) ), patch( - "traceback.extract_stack", - MagicMock( - return_value=[ - get_frame("main_path/main.py"), - get_frame(path), - get_frame(call_path), - get_frame("venv_path/logging/log.py"), - ] - ), + "homeassistant.components.system_log.sys._getframe", + return_value=logger_frame, ): wait_empty = watcher.add_watcher("error message") _LOGGER.error("error message") @@ -441,3 +444,28 @@ def __repr__(self): log = find_log(await get_error_log(hass_ws_client), "ERROR") assert log is not None assert_log(log, "", "Bad logger message: repr error", "ERROR") + + +async def test__figure_out_source(hass: HomeAssistant) -> None: + """Test that source is figured out correctly. + + We have to test this directly for exception tracebacks since + we cannot generate a trackback from a Home Assistant component + in a test because the test is not a component. + """ + try: + raise ValueError("test") + except ValueError as ex: + exc_info = (type(ex), ex, ex.__traceback__) + mock_record = MagicMock( + pathname="should not hit", + lineno=5, + exc_info=exc_info, + ) + regex_str = f"({__file__})" + file, line_no = system_log._figure_out_source( + mock_record, + re.compile(regex_str), + ) + assert file == __file__ + assert line_no != 5 diff --git a/tests/components/tado/test_config_flow.py b/tests/components/tado/test_config_flow.py index dcbb33b587ef34..c4a39914e53a23 100644 --- a/tests/components/tado/test_config_flow.py +++ b/tests/components/tado/test_config_flow.py @@ -1,5 +1,6 @@ """Test the Tado config flow.""" from http import HTTPStatus +from ipaddress import ip_address from unittest.mock import MagicMock, patch import pytest @@ -222,8 +223,8 @@ async def test_form_homekit(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, data=zeroconf.ZeroconfServiceInfo( - host="mock_host", - addresses=["mock_host"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="mock_name", port=None, @@ -249,8 +250,8 @@ async def test_form_homekit(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, data=zeroconf.ZeroconfServiceInfo( - host="mock_host", - addresses=["mock_host"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="mock_name", port=None, diff --git a/tests/components/tasmota/test_light.py b/tests/components/tasmota/test_light.py index 5c8339a6f8933c..82fa89c528013b 100644 --- a/tests/components/tasmota/test_light.py +++ b/tests/components/tasmota/test_light.py @@ -1835,3 +1835,35 @@ async def test_entity_id_update_discovery_update( await help_test_entity_id_update_discovery_update( hass, mqtt_mock, Platform.LIGHT, config ) + + +async def test_no_device_name( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota +) -> None: + """Test name of lights when no device name is set. + + When the device name is not set, Tasmota uses friendly name 1 as device naem. + This test ensures that case is handled correctly. + """ + config = copy.deepcopy(DEFAULT_CONFIG) + config["dn"] = "Light 1" + config["fn"][0] = "Light 1" + config["fn"][1] = "Light 2" + config["rl"][0] = 2 + config["rl"][1] = 2 + mac = config["mac"] + + async_fire_mqtt_message( + hass, + f"{DEFAULT_PREFIX}/{mac}/config", + json.dumps(config), + ) + await hass.async_block_till_done() + + state = hass.states.get("light.light_1") + assert state is not None + assert state.attributes["friendly_name"] == "Light 1" + + state = hass.states.get("light.light_1_light_2") + assert state is not None + assert state.attributes["friendly_name"] == "Light 1 Light 2" diff --git a/tests/components/tasmota/test_sensor.py b/tests/components/tasmota/test_sensor.py index 4e79b8ad0d568e..2f50a84ffdd1a1 100644 --- a/tests/components/tasmota/test_sensor.py +++ b/tests/components/tasmota/test_sensor.py @@ -137,6 +137,27 @@ } } +NUMBERED_SENSOR_CONFIG = { + "sn": { + "Time": "2020-09-25T12:47:15", + "ANALOG": { + "Temperature1": 2.4, + "Temperature2": 2.4, + "Illuminance3": 2.4, + }, + "TempUnit": "C", + } +} + +NUMBERED_SENSOR_CONFIG_2 = { + "sn": { + "Time": "2020-09-25T12:47:15", + "ANALOG": { + "CTEnergy1": {"Energy": 0.5, "Power": 2300, "Voltage": 230, "Current": 10}, + }, + "TempUnit": "C", + } +} TEMPERATURE_SENSOR_CONFIG = { "sn": { @@ -343,6 +364,118 @@ }, ), ), + ( + NUMBERED_SENSOR_CONFIG, + [ + "sensor.tasmota_analog_temperature1", + "sensor.tasmota_analog_temperature2", + "sensor.tasmota_analog_illuminance3", + ], + ( + ( + '{"ANALOG":{"Temperature1":1.2,"Temperature2":3.4,' + '"Illuminance3": 5.6}}' + ), + ( + '{"StatusSNS":{"ANALOG":{"Temperature1": 7.8,"Temperature2": 9.0,' + '"Illuminance3":1.2}}}' + ), + ), + ( + { + "sensor.tasmota_analog_temperature1": { + "state": "1.2", + "attributes": { + "device_class": "temperature", + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, + "unit_of_measurement": "°C", + }, + }, + "sensor.tasmota_analog_temperature2": { + "state": "3.4", + "attributes": { + "device_class": "temperature", + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, + "unit_of_measurement": "°C", + }, + }, + "sensor.tasmota_analog_illuminance3": { + "state": "5.6", + "attributes": { + "device_class": "illuminance", + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, + "unit_of_measurement": "lx", + }, + }, + }, + { + "sensor.tasmota_analog_temperature1": {"state": "7.8"}, + "sensor.tasmota_analog_temperature2": {"state": "9.0"}, + "sensor.tasmota_analog_illuminance3": {"state": "1.2"}, + }, + ), + ), + ( + NUMBERED_SENSOR_CONFIG_2, + [ + "sensor.tasmota_analog_ctenergy1_energy", + "sensor.tasmota_analog_ctenergy1_power", + "sensor.tasmota_analog_ctenergy1_voltage", + "sensor.tasmota_analog_ctenergy1_current", + ], + ( + ( + '{"ANALOG":{"CTEnergy1":' + '{"Energy":0.5,"Power":2300,"Voltage":230,"Current":10}}}' + ), + ( + '{"StatusSNS":{"ANALOG":{"CTEnergy1":' + '{"Energy":1.0,"Power":1150,"Voltage":230,"Current":5}}}}' + ), + ), + ( + { + "sensor.tasmota_analog_ctenergy1_energy": { + "state": "0.5", + "attributes": { + "device_class": "energy", + ATTR_STATE_CLASS: SensorStateClass.TOTAL, + "unit_of_measurement": "kWh", + }, + }, + "sensor.tasmota_analog_ctenergy1_power": { + "state": "2300", + "attributes": { + "device_class": "power", + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, + "unit_of_measurement": "W", + }, + }, + "sensor.tasmota_analog_ctenergy1_voltage": { + "state": "230", + "attributes": { + "device_class": "voltage", + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, + "unit_of_measurement": "V", + }, + }, + "sensor.tasmota_analog_ctenergy1_current": { + "state": "10", + "attributes": { + "device_class": "current", + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, + "unit_of_measurement": "A", + }, + }, + }, + { + "sensor.tasmota_analog_ctenergy1_energy": {"state": "1.0"}, + "sensor.tasmota_analog_ctenergy1_power": {"state": "1150"}, + "sensor.tasmota_analog_ctenergy1_voltage": {"state": "230"}, + "sensor.tasmota_analog_ctenergy1_current": {"state": "5"}, + }, + ), + ), ], ) async def test_controlling_state_via_mqtt( @@ -409,6 +542,87 @@ async def test_controlling_state_via_mqtt( assert state.attributes.get(attribute) == expected +@pytest.mark.parametrize( + ("sensor_config", "entity_ids", "states"), + [ + ( + # The AS33935 energy sensor is not reporting energy in W + {"sn": {"Time": "2020-09-25T12:47:15", "AS3935": {"Energy": None}}}, + ["sensor.tasmota_as3935_energy"], + { + "sensor.tasmota_as3935_energy": { + "device_class": None, + "state_class": None, + "unit_of_measurement": None, + }, + }, + ), + ( + # The AS33935 energy sensor is not reporting energy in W + {"sn": {"Time": "2020-09-25T12:47:15", "LD2410": {"Energy": None}}}, + ["sensor.tasmota_ld2410_energy"], + { + "sensor.tasmota_ld2410_energy": { + "device_class": None, + "state_class": None, + "unit_of_measurement": None, + }, + }, + ), + ( + # Check other energy sensors work + {"sn": {"Time": "2020-09-25T12:47:15", "Other": {"Energy": None}}}, + ["sensor.tasmota_other_energy"], + { + "sensor.tasmota_other_energy": { + "device_class": "energy", + "state_class": "total", + "unit_of_measurement": "kWh", + }, + }, + ), + ], +) +async def test_quantity_override( + hass: HomeAssistant, + mqtt_mock: MqttMockHAClient, + setup_tasmota, + sensor_config, + entity_ids, + states, +) -> None: + """Test quantity override for certain sensors.""" + entity_reg = er.async_get(hass) + config = copy.deepcopy(DEFAULT_CONFIG) + sensor_config = copy.deepcopy(sensor_config) + mac = config["mac"] + + async_fire_mqtt_message( + hass, + f"{DEFAULT_PREFIX}/{mac}/config", + json.dumps(config), + ) + await hass.async_block_till_done() + async_fire_mqtt_message( + hass, + f"{DEFAULT_PREFIX}/{mac}/sensors", + json.dumps(sensor_config), + ) + await hass.async_block_till_done() + + for entity_id in entity_ids: + state = hass.states.get(entity_id) + assert state.state == "unavailable" + expected_state = states[entity_id] + for attribute, expected in expected_state.get("attributes", {}).items(): + assert state.attributes.get(attribute) == expected + + entry = entity_reg.async_get(entity_id) + assert entry.disabled is False + assert entry.disabled_by is None + assert entry.entity_category is None + + async def test_bad_indexed_sensor_state_via_mqtt( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota ) -> None: @@ -626,6 +840,16 @@ async def test_battery_sensor_state_via_mqtt( "unit_of_measurement": "%", } + # Test polled state update + async_fire_mqtt_message( + hass, + "tasmota_49A3BC/stat/STATUS11", + '{"StatusSTS":{"BatteryPercentage":50}}', + ) + await hass.async_block_till_done() + state = hass.states.get("sensor.tasmota_battery_level") + assert state.state == "50" + @pytest.mark.parametrize("status_sensor_disabled", [False]) async def test_single_shot_status_sensor_state_via_mqtt( diff --git a/tests/components/tasmota/test_switch.py b/tests/components/tasmota/test_switch.py index b8d0ed2d060cf0..54d94b46fe89d1 100644 --- a/tests/components/tasmota/test_switch.py +++ b/tests/components/tasmota/test_switch.py @@ -283,3 +283,35 @@ async def test_entity_id_update_discovery_update( await help_test_entity_id_update_discovery_update( hass, mqtt_mock, Platform.SWITCH, config ) + + +async def test_no_device_name( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota +) -> None: + """Test name of switches when no device name is set. + + When the device name is not set, Tasmota uses friendly name 1 as device naem. + This test ensures that case is handled correctly. + """ + config = copy.deepcopy(DEFAULT_CONFIG) + config["dn"] = "Relay 1" + config["fn"][0] = "Relay 1" + config["fn"][1] = "Relay 2" + config["rl"][0] = 1 + config["rl"][1] = 1 + mac = config["mac"] + + async_fire_mqtt_message( + hass, + f"{DEFAULT_PREFIX}/{mac}/config", + json.dumps(config), + ) + await hass.async_block_till_done() + + state = hass.states.get("switch.relay_1") + assert state is not None + assert state.attributes["friendly_name"] == "Relay 1" + + state = hass.states.get("switch.relay_1_relay_2") + assert state is not None + assert state.attributes["friendly_name"] == "Relay 1 Relay 2" diff --git a/tests/components/template/test_config_flow.py b/tests/components/template/test_config_flow.py index ba939f3b8d1052..f4cfe90b9f0344 100644 --- a/tests/components/template/test_config_flow.py +++ b/tests/components/template/test_config_flow.py @@ -3,6 +3,7 @@ from unittest.mock import patch import pytest +from pytest_unordered import unordered from homeassistant import config_entries from homeassistant.components.template import DOMAIN, async_setup_entry @@ -257,6 +258,7 @@ async def test_options( "input_states", "template_states", "extra_attributes", + "listeners", ), ( ( @@ -266,14 +268,16 @@ async def test_options( {"one": "on", "two": "off"}, ["off", "on"], [{}, {}], + [["one", "two"], ["one"]], ), ( "sensor", - "{{ float(states('sensor.one')) + float(states('sensor.two')) }}", + "{{ float(states('sensor.one'), default='') + float(states('sensor.two'), default='') }}", {}, {"one": "30.0", "two": "20.0"}, - ["unavailable", "50.0"], + ["", "50.0"], [{}, {}], + [["one", "two"], ["one", "two"]], ), ), ) @@ -286,6 +290,7 @@ async def test_config_flow_preview( input_states: list[str], template_states: str, extra_attributes: list[dict[str, Any]], + listeners: list[list[str]], ) -> None: """Test the config flow preview.""" client = await hass_ws_client(hass) @@ -323,6 +328,12 @@ async def test_config_flow_preview( msg = await client.receive_json() assert msg["event"] == { "attributes": {"friendly_name": "My template"} | extra_attributes[0], + "listeners": { + "all": False, + "domains": [], + "entities": unordered([f"{template_type}.{_id}" for _id in listeners[0]]), + "time": False, + }, "state": template_states[0], } @@ -336,6 +347,12 @@ async def test_config_flow_preview( "attributes": {"friendly_name": "My template"} | extra_attributes[0] | extra_attributes[1], + "listeners": { + "all": False, + "domains": [], + "entities": unordered([f"{template_type}.{_id}" for _id in listeners[1]]), + "time": False, + }, "state": template_states[1], } assert len(hass.states.async_all()) == 2 @@ -453,6 +470,173 @@ async def test_config_flow_preview_bad_input( } +@pytest.mark.parametrize( + ( + "template_type", + "state_template", + "input_states", + "template_states", + "error_events", + ), + [ + ( + "sensor", + "{{ float(states('sensor.one')) + float(states('sensor.two')) }}", + {"one": "30.0", "two": "20.0"}, + ["unavailable", "50.0"], + [ + ( + "ValueError: Template error: float got invalid input 'unknown' " + "when rendering template '{{ float(states('sensor.one')) + " + "float(states('sensor.two')) }}' but no default was specified" + ) + ], + ), + ], +) +async def test_config_flow_preview_template_startup_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + template_type: str, + state_template: str, + input_states: dict[str, str], + template_states: list[str], + error_events: list[str], +) -> None: + """Test the config flow preview.""" + client = await hass_ws_client(hass) + + input_entities = ["one", "two"] + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.MENU + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": template_type}, + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == template_type + assert result["errors"] is None + assert result["preview"] == "template" + + await client.send_json_auto_id( + { + "type": "template/start_preview", + "flow_id": result["flow_id"], + "flow_type": "config_flow", + "user_input": {"name": "My template", "state": state_template}, + } + ) + msg = await client.receive_json() + assert msg["type"] == "result" + assert msg["success"] + + for error_event in error_events: + msg = await client.receive_json() + assert msg["type"] == "event" + assert msg["event"] == {"error": error_event} + + msg = await client.receive_json() + assert msg["type"] == "event" + assert msg["event"]["state"] == template_states[0] + + for input_entity in input_entities: + hass.states.async_set( + f"{template_type}.{input_entity}", input_states[input_entity], {} + ) + + msg = await client.receive_json() + assert msg["type"] == "event" + assert msg["event"]["state"] == template_states[1] + + +@pytest.mark.parametrize( + ( + "template_type", + "state_template", + "input_states", + "template_states", + "error_events", + ), + [ + ( + "sensor", + "{{ float(states('sensor.one')) > 30 and undefined_function() }}", + [{"one": "30.0", "two": "20.0"}, {"one": "35.0", "two": "20.0"}], + ["False", "unavailable"], + ["'undefined_function' is undefined"], + ), + ], +) +async def test_config_flow_preview_template_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + template_type: str, + state_template: str, + input_states: list[dict[str, str]], + template_states: list[str], + error_events: list[str], +) -> None: + """Test the config flow preview.""" + client = await hass_ws_client(hass) + + input_entities = ["one", "two"] + + for input_entity in input_entities: + hass.states.async_set( + f"{template_type}.{input_entity}", input_states[0][input_entity], {} + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.MENU + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": template_type}, + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == template_type + assert result["errors"] is None + assert result["preview"] == "template" + + await client.send_json_auto_id( + { + "type": "template/start_preview", + "flow_id": result["flow_id"], + "flow_type": "config_flow", + "user_input": {"name": "My template", "state": state_template}, + } + ) + msg = await client.receive_json() + assert msg["type"] == "result" + assert msg["success"] + + msg = await client.receive_json() + assert msg["type"] == "event" + assert msg["event"]["state"] == template_states[0] + + for input_entity in input_entities: + hass.states.async_set( + f"{template_type}.{input_entity}", input_states[1][input_entity], {} + ) + + for error_event in error_events: + msg = await client.receive_json() + assert msg["type"] == "event" + assert msg["event"] == {"error": error_event} + + msg = await client.receive_json() + assert msg["type"] == "event" + assert msg["event"]["state"] == template_states[1] + + @pytest.mark.parametrize( ( "template_type", @@ -526,6 +710,7 @@ async def test_config_flow_preview_bad_state( "input_states", "template_state", "extra_attributes", + "listeners", ), [ ( @@ -537,6 +722,7 @@ async def test_config_flow_preview_bad_state( {"one": "on", "two": "off"}, "off", {}, + ["one", "two"], ), ( "sensor", @@ -547,6 +733,7 @@ async def test_config_flow_preview_bad_state( {"one": "30.0", "two": "20.0"}, "10.0", {}, + ["one", "two"], ), ], ) @@ -561,6 +748,7 @@ async def test_option_flow_preview( input_states: list[str], template_state: str, extra_attributes: dict[str, Any], + listeners: list[str], ) -> None: """Test the option flow preview.""" client = await hass_ws_client(hass) @@ -608,6 +796,12 @@ async def test_option_flow_preview( msg = await client.receive_json() assert msg["event"] == { "attributes": {"friendly_name": "My template"} | extra_attributes, + "listeners": { + "all": False, + "domains": [], + "entities": unordered([f"{template_type}.{_id}" for _id in listeners]), + "time": False, + }, "state": template_state, } assert len(hass.states.async_all()) == 3 diff --git a/tests/components/template/test_sensor.py b/tests/components/template/test_sensor.py index 47e307bc6aacd9..0ca666d22f1e60 100644 --- a/tests/components/template/test_sensor.py +++ b/tests/components/template/test_sensor.py @@ -1,7 +1,7 @@ """The test for the Template sensor platform.""" from asyncio import Event from datetime import timedelta -from unittest.mock import patch +from unittest.mock import ANY, patch import pytest from syrupy.assertion import SnapshotAssertion @@ -1192,6 +1192,48 @@ async def test_trigger_entity( assert state.context is context +@pytest.mark.parametrize(("count", "domain"), [(1, "template")]) +@pytest.mark.parametrize( + "config", + [ + { + "template": [ + { + "trigger": {"platform": "event", "event_type": "test_event"}, + "sensors": { + "hello": { + "friendly_name": "Hello Name", + "value_template": "{{ trigger.event.data.beer }}", + "entity_picture_template": "{{ '/local/dogs.png' }}", + "icon_template": "{{ 'mdi:pirate' }}", + "attribute_templates": { + "last": "{{now().strftime('%D %X')}}", + "history_1": "{{this.attributes.last|default('Not yet set')}}", + }, + }, + }, + }, + ], + }, + ], +) +async def test_trigger_entity_runs_once( + hass: HomeAssistant, start_ha, entity_registry: er.EntityRegistry +) -> None: + """Test trigger entity handles a trigger once.""" + state = hass.states.get("sensor.hello_name") + assert state is not None + assert state.state == STATE_UNKNOWN + + hass.bus.async_fire("test_event", {"beer": 2}) + await hass.async_block_till_done() + + state = hass.states.get("sensor.hello_name") + assert state.state == "2" + assert state.attributes.get("last") == ANY + assert state.attributes.get("history_1") == "Not yet set" + + @pytest.mark.parametrize(("count", "domain"), [(1, "template")]) @pytest.mark.parametrize( "config", @@ -1582,3 +1624,47 @@ async def test_trigger_entity_restore_state( assert state.attributes["entity_picture"] == "/local/dogs.png" assert state.attributes["plus_one"] == 3 assert state.attributes["another"] == 1 + + +@pytest.mark.parametrize(("count", "domain"), [(1, "template")]) +@pytest.mark.parametrize( + "config", + [ + { + "template": [ + { + "unique_id": "listening-test-event", + "trigger": {"platform": "event", "event_type": "test_event"}, + "action": [ + { + "variables": { + "my_variable": "{{ trigger.event.data.beer + 1 }}" + }, + }, + ], + "sensor": [ + { + "name": "Hello Name", + "state": "{{ my_variable + 1 }}", + } + ], + }, + ], + }, + ], +) +async def test_trigger_action( + hass: HomeAssistant, start_ha, entity_registry: er.EntityRegistry +) -> None: + """Test trigger entity with an action works.""" + state = hass.states.get("sensor.hello_name") + assert state is not None + assert state.state == STATE_UNKNOWN + + context = Context() + hass.bus.async_fire("test_event", {"beer": 1}, context=context) + await hass.async_block_till_done() + + state = hass.states.get("sensor.hello_name") + assert state.state == "3" + assert state.context is context diff --git a/tests/components/thread/test_config_flow.py b/tests/components/thread/test_config_flow.py index 7ff096795ca82c..51ebe3b5976370 100644 --- a/tests/components/thread/test_config_flow.py +++ b/tests/components/thread/test_config_flow.py @@ -1,4 +1,5 @@ """Test the Thread config flow.""" +from ipaddress import ip_address from unittest.mock import patch from homeassistant.components import thread, zeroconf @@ -6,10 +7,10 @@ from homeassistant.data_entry_flow import FlowResultType TEST_ZEROCONF_RECORD = zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="HomeAssistant OpenThreadBorderRouter #0BBF", name="HomeAssistant OpenThreadBorderRouter #0BBF._meshcop._udp.local.", - addresses=["127.0.0.1"], port=8080, properties={ "rv": "1", diff --git a/tests/components/thread/test_diagnostics.py b/tests/components/thread/test_diagnostics.py index 94ca437371587d..15ab07503162af 100644 --- a/tests/components/thread/test_diagnostics.py +++ b/tests/components/thread/test_diagnostics.py @@ -1,7 +1,6 @@ """Test the thread websocket API.""" import dataclasses -import time from unittest.mock import Mock, patch import pytest @@ -191,50 +190,49 @@ async def test_diagnostics( """Test diagnostics for thread routers.""" cache = mock_async_zeroconf.zeroconf.cache = DNSCache() - now = time.monotonic() * 1000 cache.async_add_records( [ - *TEST_ZEROCONF_RECORD_1.dns_addresses(created=now), - TEST_ZEROCONF_RECORD_1.dns_service(created=now), - TEST_ZEROCONF_RECORD_1.dns_text(created=now), - TEST_ZEROCONF_RECORD_1.dns_pointer(created=now), + *TEST_ZEROCONF_RECORD_1.dns_addresses(), + TEST_ZEROCONF_RECORD_1.dns_service(), + TEST_ZEROCONF_RECORD_1.dns_text(), + TEST_ZEROCONF_RECORD_1.dns_pointer(), ] ) cache.async_add_records( [ - *TEST_ZEROCONF_RECORD_2.dns_addresses(created=now), - TEST_ZEROCONF_RECORD_2.dns_service(created=now), - TEST_ZEROCONF_RECORD_2.dns_text(created=now), - TEST_ZEROCONF_RECORD_2.dns_pointer(created=now), + *TEST_ZEROCONF_RECORD_2.dns_addresses(), + TEST_ZEROCONF_RECORD_2.dns_service(), + TEST_ZEROCONF_RECORD_2.dns_text(), + TEST_ZEROCONF_RECORD_2.dns_pointer(), ] ) # Test for invalid cache - cache.async_add_records([TEST_ZEROCONF_RECORD_3.dns_pointer(created=now)]) + cache.async_add_records([TEST_ZEROCONF_RECORD_3.dns_pointer()]) # Test for invalid record cache.async_add_records( [ - *TEST_ZEROCONF_RECORD_4.dns_addresses(created=now), - TEST_ZEROCONF_RECORD_4.dns_service(created=now), - TEST_ZEROCONF_RECORD_4.dns_text(created=now), - TEST_ZEROCONF_RECORD_4.dns_pointer(created=now), + *TEST_ZEROCONF_RECORD_4.dns_addresses(), + TEST_ZEROCONF_RECORD_4.dns_service(), + TEST_ZEROCONF_RECORD_4.dns_text(), + TEST_ZEROCONF_RECORD_4.dns_pointer(), ] ) # Test for record without xa cache.async_add_records( [ - *TEST_ZEROCONF_RECORD_5.dns_addresses(created=now), - TEST_ZEROCONF_RECORD_5.dns_service(created=now), - TEST_ZEROCONF_RECORD_5.dns_text(created=now), - TEST_ZEROCONF_RECORD_5.dns_pointer(created=now), + *TEST_ZEROCONF_RECORD_5.dns_addresses(), + TEST_ZEROCONF_RECORD_5.dns_service(), + TEST_ZEROCONF_RECORD_5.dns_text(), + TEST_ZEROCONF_RECORD_5.dns_pointer(), ] ) # Test for record without xp cache.async_add_records( [ - *TEST_ZEROCONF_RECORD_6.dns_addresses(created=now), - TEST_ZEROCONF_RECORD_6.dns_service(created=now), - TEST_ZEROCONF_RECORD_6.dns_text(created=now), - TEST_ZEROCONF_RECORD_6.dns_pointer(created=now), + *TEST_ZEROCONF_RECORD_6.dns_addresses(), + TEST_ZEROCONF_RECORD_6.dns_service(), + TEST_ZEROCONF_RECORD_6.dns_text(), + TEST_ZEROCONF_RECORD_6.dns_pointer(), ] ) assert await async_setup_component(hass, DOMAIN, {}) diff --git a/tests/components/timer/test_init.py b/tests/components/timer/test_init.py index 7bc2df87f35702..eabc5e04e0bce5 100644 --- a/tests/components/timer/test_init.py +++ b/tests/components/timer/test_init.py @@ -46,11 +46,7 @@ ) from homeassistant.core import Context, CoreState, HomeAssistant, State from homeassistant.exceptions import HomeAssistantError, Unauthorized -from homeassistant.helpers import ( - config_validation as cv, - entity_registry as er, - issue_registry as ir, -) +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.restore_state import StoredState, async_get from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow @@ -270,9 +266,7 @@ def fake_event_listener(event): @pytest.mark.freeze_time("2023-06-05 17:47:50") -async def test_start_service( - hass: HomeAssistant, issue_registry: ir.IssueRegistry -) -> None: +async def test_start_service(hass: HomeAssistant) -> None: """Test the start/stop service.""" await async_setup_component(hass, DOMAIN, {DOMAIN: {"test1": {CONF_DURATION: 10}}}) @@ -317,12 +311,6 @@ async def test_start_service( blocking=True, ) await hass.async_block_till_done() - - # Ensure an issue is raised for the use of this deprecated service - assert issue_registry.async_get_issue( - domain=DOMAIN, issue_id="deprecated_duration_in_start" - ) - state = hass.states.get("timer.test1") assert state assert state.state == STATUS_ACTIVE diff --git a/tests/components/todoist/conftest.py b/tests/components/todoist/conftest.py new file mode 100644 index 00000000000000..6543e5b678f42b --- /dev/null +++ b/tests/components/todoist/conftest.py @@ -0,0 +1,135 @@ +"""Common fixtures for the todoist tests.""" +from collections.abc import Generator +from http import HTTPStatus +from unittest.mock import AsyncMock, patch + +import pytest +from requests.exceptions import HTTPError +from requests.models import Response +from todoist_api_python.models import Collaborator, Due, Label, Project, Task + +from homeassistant.components.todoist import DOMAIN +from homeassistant.const import CONF_TOKEN +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 + +SUMMARY = "A task" +TOKEN = "some-token" + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.todoist.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture(name="due") +def mock_due() -> Due: + """Mock a todoist Task Due date/time.""" + return Due( + is_recurring=False, date=dt_util.now().strftime("%Y-%m-%d"), string="today" + ) + + +@pytest.fixture(name="task") +def mock_task(due: Due) -> Task: + """Mock a todoist Task instance.""" + return Task( + assignee_id="1", + assigner_id="1", + comment_count=0, + is_completed=False, + content=SUMMARY, + created_at="2021-10-01T00:00:00", + creator_id="1", + description="A task", + due=due, + id="1", + labels=["Label1"], + order=1, + parent_id=None, + priority=1, + project_id="12345", + section_id=None, + url="https://todoist.com", + sync_id=None, + ) + + +@pytest.fixture(name="api") +def mock_api(task) -> AsyncMock: + """Mock the api state.""" + api = AsyncMock() + api.get_projects.return_value = [ + Project( + id="12345", + color="blue", + comment_count=0, + is_favorite=False, + name="Name", + is_shared=False, + url="", + is_inbox_project=False, + is_team_inbox=False, + order=1, + parent_id=None, + view_style="list", + ) + ] + api.get_labels.return_value = [ + Label(id="1", name="Label1", color="1", order=1, is_favorite=False) + ] + api.get_collaborators.return_value = [ + Collaborator(email="user@gmail.com", id="1", name="user") + ] + api.get_tasks.return_value = [task] + return api + + +@pytest.fixture(name="todoist_api_status") +def mock_api_status() -> HTTPStatus | None: + """Fixture to inject an http status error.""" + return None + + +@pytest.fixture(autouse=True) +def mock_api_side_effect( + api: AsyncMock, todoist_api_status: HTTPStatus | None +) -> MockConfigEntry: + """Mock todoist configuration.""" + if todoist_api_status: + response = Response() + response.status_code = todoist_api_status + api.get_tasks.side_effect = HTTPError(response=response) + + +@pytest.fixture(name="todoist_config_entry") +def mock_todoist_config_entry() -> MockConfigEntry: + """Mock todoist configuration.""" + return MockConfigEntry(domain=DOMAIN, unique_id=TOKEN, data={CONF_TOKEN: TOKEN}) + + +@pytest.fixture(name="todoist_domain") +def mock_todoist_domain() -> str: + """Mock todoist configuration.""" + return DOMAIN + + +@pytest.fixture(name="setup_integration") +async def mock_setup_integration( + hass: HomeAssistant, + api: AsyncMock, + todoist_config_entry: MockConfigEntry | None, +) -> None: + """Mock setup of the todoist integration.""" + if todoist_config_entry is not None: + todoist_config_entry.add_to_hass(hass) + with patch("homeassistant.components.todoist.TodoistAPIAsync", return_value=api): + assert await async_setup_component(hass, DOMAIN, {}) + yield diff --git a/tests/components/todoist/test_calendar.py b/tests/components/todoist/test_calendar.py index 921439fab45533..45300e2e66cc5b 100644 --- a/tests/components/todoist/test_calendar.py +++ b/tests/components/todoist/test_calendar.py @@ -7,7 +7,7 @@ import zoneinfo import pytest -from todoist_api_python.models import Collaborator, Due, Label, Project, Task +from todoist_api_python.models import Due from homeassistant import setup from homeassistant.components.todoist.const import ( @@ -24,9 +24,10 @@ from homeassistant.helpers.entity_component import async_update_entity from homeassistant.util import dt as dt_util +from .conftest import SUMMARY + from tests.typing import ClientSessionGenerator -SUMMARY = "A task" # Set our timezone to CST/Regina so we can check calculations # This keeps UTC-6 all year round TZ_NAME = "America/Regina" @@ -39,69 +40,6 @@ def set_time_zone(hass: HomeAssistant): hass.config.set_time_zone(TZ_NAME) -@pytest.fixture(name="due") -def mock_due() -> Due: - """Mock a todoist Task Due date/time.""" - return Due( - is_recurring=False, date=dt_util.now().strftime("%Y-%m-%d"), string="today" - ) - - -@pytest.fixture(name="task") -def mock_task(due: Due) -> Task: - """Mock a todoist Task instance.""" - return Task( - assignee_id="1", - assigner_id="1", - comment_count=0, - is_completed=False, - content=SUMMARY, - created_at="2021-10-01T00:00:00", - creator_id="1", - description="A task", - due=due, - id="1", - labels=["Label1"], - order=1, - parent_id=None, - priority=1, - project_id="12345", - section_id=None, - url="https://todoist.com", - sync_id=None, - ) - - -@pytest.fixture(name="api") -def mock_api(task) -> AsyncMock: - """Mock the api state.""" - api = AsyncMock() - api.get_projects.return_value = [ - Project( - id="12345", - color="blue", - comment_count=0, - is_favorite=False, - name="Name", - is_shared=False, - url="", - is_inbox_project=False, - is_team_inbox=False, - order=1, - parent_id=None, - view_style="list", - ) - ] - api.get_labels.return_value = [ - Label(id="1", name="Label1", color="1", order=1, is_favorite=False) - ] - api.get_collaborators.return_value = [ - Collaborator(email="user@gmail.com", id="1", name="user") - ] - api.get_tasks.return_value = [task] - return api - - def get_events_url(entity: str, start: str, end: str) -> str: """Create a url to get events during the specified time range.""" return f"/api/calendars/{entity}?start={urllib.parse.quote(start)}&end={urllib.parse.quote(end)}" @@ -127,8 +65,8 @@ def mock_todoist_config() -> dict[str, Any]: return {} -@pytest.fixture(name="setup_integration", autouse=True) -async def mock_setup_integration( +@pytest.fixture(name="setup_platform", autouse=True) +async def mock_setup_platform( hass: HomeAssistant, api: AsyncMock, todoist_config: dict[str, Any], @@ -215,7 +153,7 @@ async def test_update_entity_for_calendar_with_due_date_in_the_future( assert state.attributes["end_time"] == expected_end_time -@pytest.mark.parametrize("setup_integration", [None]) +@pytest.mark.parametrize("setup_platform", [None]) async def test_failed_coordinator_update(hass: HomeAssistant, api: AsyncMock) -> None: """Test a failed data coordinator update is handled correctly.""" api.get_tasks.side_effect = Exception("API error") @@ -417,3 +355,44 @@ async def test_task_due_datetime( ) assert response.status == HTTPStatus.OK assert await response.json() == [] + + +@pytest.mark.parametrize( + ("due", "setup_platform"), + [ + ( + Due( + date="2023-03-30", + is_recurring=False, + string="Mar 30 6:00 PM", + datetime="2023-03-31T00:00:00Z", + timezone="America/Regina", + ), + None, + ) + ], +) +async def test_config_entry( + hass: HomeAssistant, + setup_integration: None, + hass_client: ClientSessionGenerator, +) -> None: + """Test for a calendar created with a config entry.""" + + await async_update_entity(hass, "calendar.name") + state = hass.states.get("calendar.name") + assert state + + client = await hass_client() + response = await client.get( + get_events_url( + "calendar.name", "2023-03-30T08:00:00.000Z", "2023-03-31T08:00:00.000Z" + ), + ) + assert response.status == HTTPStatus.OK + assert await response.json() == [ + get_events_response( + {"dateTime": "2023-03-30T18:00:00-06:00"}, + {"dateTime": "2023-03-31T18:00:00-06:00"}, + ) + ] diff --git a/tests/components/todoist/test_config_flow.py b/tests/components/todoist/test_config_flow.py new file mode 100644 index 00000000000000..4175902da3131b --- /dev/null +++ b/tests/components/todoist/test_config_flow.py @@ -0,0 +1,123 @@ +"""Test the todoist config flow.""" + +from http import HTTPStatus +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant import config_entries +from homeassistant.components.todoist.const import DOMAIN +from homeassistant.const import CONF_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .conftest import TOKEN + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +@pytest.fixture(autouse=True) +async def patch_api( + api: AsyncMock, +) -> None: + """Mock setup of the todoist integration.""" + with patch( + "homeassistant.components.todoist.config_flow.TodoistAPIAsync", return_value=api + ): + yield + + +async def test_form( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, +) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result.get("type") == FlowResultType.FORM + assert not result.get("errors") + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_TOKEN: TOKEN, + }, + ) + await hass.async_block_till_done() + + assert result2.get("type") == FlowResultType.CREATE_ENTRY + assert result2.get("title") == "Todoist" + assert result2.get("data") == { + CONF_TOKEN: TOKEN, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize("todoist_api_status", [HTTPStatus.UNAUTHORIZED]) +async def test_form_invalid_auth(hass: HomeAssistant) -> None: + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_TOKEN: TOKEN, + }, + ) + + assert result2.get("type") == FlowResultType.FORM + assert result2.get("errors") == {"base": "invalid_access_token"} + + +@pytest.mark.parametrize("todoist_api_status", [HTTPStatus.INTERNAL_SERVER_ERROR]) +async def test_form_cannot_connect(hass: HomeAssistant) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_TOKEN: TOKEN, + }, + ) + + assert result2.get("type") == FlowResultType.FORM + assert result2.get("errors") == {"base": "cannot_connect"} + + +@pytest.mark.parametrize("todoist_api_status", [HTTPStatus.UNAUTHORIZED]) +async def test_unknown_error(hass: HomeAssistant, api: AsyncMock) -> None: + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + api.get_tasks.side_effect = ValueError("unexpected") + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_TOKEN: TOKEN, + }, + ) + + assert result2.get("type") == FlowResultType.FORM + assert result2.get("errors") == {"base": "unknown"} + + +async def test_already_configured(hass: HomeAssistant, setup_integration: None) -> None: + """Test that only a single instance can be configured.""" + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "single_instance_allowed" diff --git a/tests/components/todoist/test_init.py b/tests/components/todoist/test_init.py new file mode 100644 index 00000000000000..cc64464df1d0d7 --- /dev/null +++ b/tests/components/todoist/test_init.py @@ -0,0 +1,47 @@ +"""Unit tests for the Todoist integration.""" +from collections.abc import Generator +from http import HTTPStatus +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.todoist.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.fixture(autouse=True) +def mock_platforms() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.todoist.PLATFORMS", return_value=[] + ) as mock_setup_entry: + yield mock_setup_entry + + +async def test_load_unload( + hass: HomeAssistant, + setup_integration: None, + todoist_config_entry: MockConfigEntry | None, +) -> None: + """Test loading and unloading of the config entry.""" + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + + assert todoist_config_entry.state == ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(todoist_config_entry.entry_id) + assert todoist_config_entry.state == ConfigEntryState.NOT_LOADED + + +@pytest.mark.parametrize("todoist_api_status", [HTTPStatus.INTERNAL_SERVER_ERROR]) +async def test_init_failure( + hass: HomeAssistant, + setup_integration: None, + api: AsyncMock, + todoist_config_entry: MockConfigEntry | None, +) -> None: + """Test an initialization error on integration load.""" + assert todoist_config_entry.state == ConfigEntryState.SETUP_RETRY diff --git a/tests/components/tolo/test_config_flow.py b/tests/components/tolo/test_config_flow.py index aa88766c395507..df36570497bf3b 100644 --- a/tests/components/tolo/test_config_flow.py +++ b/tests/components/tolo/test_config_flow.py @@ -23,6 +23,18 @@ def toloclient_fixture() -> Mock: yield toloclient +@pytest.fixture +def coordinator_toloclient() -> Mock: + """Patch ToloClient in async_setup_entry. + + Throw exception to abort entry setup and prevent socket IO. Only testing config flow. + """ + with patch( + "homeassistant.components.tolo.ToloClient", side_effect=Exception + ) as toloclient: + yield toloclient + + async def test_user_with_timed_out_host(hass: HomeAssistant, toloclient: Mock) -> None: """Test a user initiated config flow with provided host which times out.""" toloclient().get_status_info.side_effect = ResponseTimedOutError() @@ -38,7 +50,9 @@ async def test_user_with_timed_out_host(hass: HomeAssistant, toloclient: Mock) - assert result["errors"] == {"base": "cannot_connect"} -async def test_user_walkthrough(hass: HomeAssistant, toloclient: Mock) -> None: +async def test_user_walkthrough( + hass: HomeAssistant, toloclient: Mock, coordinator_toloclient: Mock +) -> None: """Test complete user flow with first wrong and then correct host.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} @@ -70,7 +84,9 @@ async def test_user_walkthrough(hass: HomeAssistant, toloclient: Mock) -> None: assert result3["data"][CONF_HOST] == "127.0.0.1" -async def test_dhcp(hass: HomeAssistant, toloclient: Mock) -> None: +async def test_dhcp( + hass: HomeAssistant, toloclient: Mock, coordinator_toloclient: Mock +) -> None: """Test starting a flow from discovery.""" toloclient().get_status_info.side_effect = lambda *args, **kwargs: object() diff --git a/tests/components/tomorrowio/test_weather.py b/tests/components/tomorrowio/test_weather.py index a6a5e93561405f..229e62065a69da 100644 --- a/tests/components/tomorrowio/test_weather.py +++ b/tests/components/tomorrowio/test_weather.py @@ -153,9 +153,66 @@ async def test_legacy_config_entry(hass: HomeAssistant) -> None: assert len(er.async_entries_for_config_entry(registry, entry.entry_id)) == 30 -async def test_v4_weather(hass: HomeAssistant) -> None: +async def test_v4_weather(hass: HomeAssistant, tomorrowio_config_entry_update) -> None: """Test v4 weather data.""" weather_state = await _setup(hass, API_V4_ENTRY_DATA) + + tomorrowio_config_entry_update.assert_called_with( + [ + "temperature", + "humidity", + "pressureSeaLevel", + "windSpeed", + "windDirection", + "weatherCode", + "visibility", + "pollutantO3", + "windGust", + "cloudCover", + "precipitationType", + "pollutantCO", + "mepIndex", + "mepHealthConcern", + "mepPrimaryPollutant", + "cloudBase", + "cloudCeiling", + "cloudCover", + "dewPoint", + "epaIndex", + "epaHealthConcern", + "epaPrimaryPollutant", + "temperatureApparent", + "fireIndex", + "pollutantNO2", + "pollutantO3", + "particulateMatter10", + "particulateMatter25", + "grassIndex", + "treeIndex", + "weedIndex", + "precipitationType", + "pressureSurfaceLevel", + "solarGHI", + "pollutantSO2", + "uvIndex", + "uvHealthConcern", + "windGust", + ], + [ + "temperatureMin", + "temperatureMax", + "dewPoint", + "humidity", + "windSpeed", + "windDirection", + "weatherCode", + "precipitationIntensityAvg", + "precipitationProbability", + ], + nowcast_timestep=60, + location="80.0,80.0", + ) + assert weather_state.state == ATTR_CONDITION_SUNNY assert weather_state.attributes[ATTR_ATTRIBUTION] == ATTRIBUTION assert len(weather_state.attributes[ATTR_FORECAST]) == 14 diff --git a/tests/components/tradfri/test_config_flow.py b/tests/components/tradfri/test_config_flow.py index 9eff7335820457..3f5c71645c8e6f 100644 --- a/tests/components/tradfri/test_config_flow.py +++ b/tests/components/tradfri/test_config_flow.py @@ -1,4 +1,5 @@ """Test the Tradfri config flow.""" +from ipaddress import ip_address from unittest.mock import AsyncMock, patch import pytest @@ -113,8 +114,8 @@ async def test_discovery_connection( "tradfri", context={"source": config_entries.SOURCE_HOMEKIT}, data=zeroconf.ZeroconfServiceInfo( - host="123.123.123.123", - addresses=["123.123.123.123"], + ip_address=ip_address("123.123.123.123"), + ip_addresses=[ip_address("123.123.123.123")], hostname="mock_hostname", name="mock_name", port=None, @@ -148,8 +149,8 @@ async def test_discovery_duplicate_aborted(hass: HomeAssistant) -> None: "tradfri", context={"source": config_entries.SOURCE_HOMEKIT}, data=zeroconf.ZeroconfServiceInfo( - host="new-host", - addresses=["new-host"], + ip_address=ip_address("123.123.123.124"), + ip_addresses=[ip_address("123.123.123.124")], hostname="mock_hostname", name="mock_name", port=None, @@ -161,7 +162,7 @@ async def test_discovery_duplicate_aborted(hass: HomeAssistant) -> None: assert flow["type"] == data_entry_flow.FlowResultType.ABORT assert flow["reason"] == "already_configured" - assert entry.data["host"] == "new-host" + assert entry.data["host"] == "123.123.123.124" async def test_duplicate_discovery( @@ -172,8 +173,8 @@ async def test_duplicate_discovery( "tradfri", context={"source": config_entries.SOURCE_HOMEKIT}, data=zeroconf.ZeroconfServiceInfo( - host="123.123.123.123", - addresses=["123.123.123.123"], + ip_address=ip_address("123.123.123.123"), + ip_addresses=[ip_address("123.123.123.123")], hostname="mock_hostname", name="mock_name", port=None, @@ -188,8 +189,8 @@ async def test_duplicate_discovery( "tradfri", context={"source": config_entries.SOURCE_HOMEKIT}, data=zeroconf.ZeroconfServiceInfo( - host="123.123.123.123", - addresses=["123.123.123.123"], + ip_address=ip_address("123.123.123.123"), + ip_addresses=[ip_address("123.123.123.123")], hostname="mock_hostname", name="mock_name", port=None, @@ -205,7 +206,7 @@ async def test_discovery_updates_unique_id(hass: HomeAssistant) -> None: """Test a duplicate discovery host aborts and updates existing entry.""" entry = MockConfigEntry( domain="tradfri", - data={"host": "some-host"}, + data={"host": "123.123.123.123"}, ) entry.add_to_hass(hass) @@ -213,8 +214,8 @@ async def test_discovery_updates_unique_id(hass: HomeAssistant) -> None: "tradfri", context={"source": config_entries.SOURCE_HOMEKIT}, data=zeroconf.ZeroconfServiceInfo( - host="some-host", - addresses=["some-host"], + ip_address=ip_address("123.123.123.123"), + ip_addresses=[ip_address("123.123.123.123")], hostname="mock_hostname", name="mock_name", port=None, diff --git a/tests/components/trafikverket_camera/test_binary_sensor.py b/tests/components/trafikverket_camera/test_binary_sensor.py new file mode 100644 index 00000000000000..6f7eb54028917e --- /dev/null +++ b/tests/components/trafikverket_camera/test_binary_sensor.py @@ -0,0 +1,20 @@ +"""The test for the Trafikverket binary sensor platform.""" +from __future__ import annotations + +from pytrafikverket.trafikverket_camera import CameraInfo + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_ON +from homeassistant.core import HomeAssistant + + +async def test_sensor( + hass: HomeAssistant, + entity_registry_enabled_by_default: None, + load_int: ConfigEntry, + get_camera: CameraInfo, +) -> None: + """Test the Trafikverket Camera binary sensor.""" + + state = hass.states.get("binary_sensor.test_location_active") + assert state.state == STATE_ON diff --git a/tests/components/trafikverket_camera/test_config_flow.py b/tests/components/trafikverket_camera/test_config_flow.py index 38c49d5420871d..aa6122b7efede1 100644 --- a/tests/components/trafikverket_camera/test_config_flow.py +++ b/tests/components/trafikverket_camera/test_config_flow.py @@ -10,6 +10,7 @@ NoCameraFound, UnknownError, ) +from pytrafikverket.trafikverket_camera import CameraInfo from homeassistant import config_entries from homeassistant.components.trafikverket_camera.const import CONF_LOCATION, DOMAIN @@ -20,7 +21,7 @@ from tests.common import MockConfigEntry -async def test_form(hass: HomeAssistant) -> None: +async def test_form(hass: HomeAssistant, get_camera: CameraInfo) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( @@ -31,6 +32,7 @@ async def test_form(hass: HomeAssistant) -> None: with patch( "homeassistant.components.trafikverket_camera.config_flow.TrafikverketCamera.async_get_camera", + return_value=get_camera, ), patch( "homeassistant.components.trafikverket_camera.async_setup_entry", return_value=True, @@ -39,7 +41,7 @@ async def test_form(hass: HomeAssistant) -> None: result["flow_id"], { CONF_API_KEY: "1234567890", - CONF_LOCATION: "Test location", + CONF_LOCATION: "Test loc", }, ) await hass.async_block_till_done() diff --git a/tests/components/trafikverket_camera/test_recorder.py b/tests/components/trafikverket_camera/test_recorder.py index 021433b33e7348..b9add7ae4830bd 100644 --- a/tests/components/trafikverket_camera/test_recorder.py +++ b/tests/components/trafikverket_camera/test_recorder.py @@ -16,6 +16,7 @@ async def test_exclude_attributes( recorder_mock: Recorder, + entity_registry_enabled_by_default: None, hass: HomeAssistant, load_int: ConfigEntry, monkeypatch: pytest.MonkeyPatch, @@ -37,10 +38,12 @@ async def test_exclude_attributes( None, hass.states.async_entity_ids(), ) - assert len(states) == 1 + assert len(states) == 8 assert states.get("camera.test_location") for entity_states in states.values(): for state in entity_states: - assert "location" not in state.attributes - assert "description" not in state.attributes - assert "type" in state.attributes + if state.entity_id == "camera.test_location": + assert "location" not in state.attributes + assert "description" not in state.attributes + assert "type" in state.attributes + break diff --git a/tests/components/trafikverket_camera/test_sensor.py b/tests/components/trafikverket_camera/test_sensor.py new file mode 100644 index 00000000000000..581fed1d28960e --- /dev/null +++ b/tests/components/trafikverket_camera/test_sensor.py @@ -0,0 +1,29 @@ +"""The test for the Trafikverket sensor platform.""" +from __future__ import annotations + +from pytrafikverket.trafikverket_camera import CameraInfo + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + + +async def test_sensor( + hass: HomeAssistant, + entity_registry_enabled_by_default: None, + load_int: ConfigEntry, + get_camera: CameraInfo, +) -> None: + """Test the Trafikverket Camera sensor.""" + + state = hass.states.get("sensor.test_location_direction") + assert state.state == "180" + state = hass.states.get("sensor.test_location_modified") + assert state.state == "2022-04-04T04:04:04+00:00" + state = hass.states.get("sensor.test_location_photo_time") + assert state.state == "2022-04-04T04:04:04+00:00" + state = hass.states.get("sensor.test_location_photo_url") + assert state.state == "https://www.testurl.com/test_photo.jpg" + state = hass.states.get("sensor.test_location_status") + assert state.state == "Running" + state = hass.states.get("sensor.test_location_camera_type") + assert state.state == "Road" diff --git a/tests/components/twinkly/__init__.py b/tests/components/twinkly/__init__.py index 31d1eff2a61ff6..0780bc0126f871 100644 --- a/tests/components/twinkly/__init__.py +++ b/tests/components/twinkly/__init__.py @@ -1,6 +1,5 @@ """Constants and mock for the twkinly component tests.""" -from uuid import uuid4 from aiohttp.client_exceptions import ClientConnectionError @@ -8,6 +7,7 @@ TEST_HOST = "test.twinkly.com" TEST_ID = "twinkly_test_device_id" +TEST_UID = "4c8fccf5-e08a-4173-92d5-49bf479252a2" TEST_NAME = "twinkly_test_device_name" TEST_NAME_ORIGINAL = "twinkly_test_original_device_name" # the original (deprecated) name stored in the conf TEST_MODEL = "twinkly_test_device_model" @@ -28,11 +28,12 @@ def __init__(self) -> None: self.mode = None self.version = "2.8.10" - self.id = str(uuid4()) + self.id = TEST_UID self.device_info = { "uuid": self.id, - "device_name": self.id, # we make sure that entity id is different for each test + "device_name": TEST_NAME, "product_code": TEST_MODEL, + "sw_version": self.version, } @property diff --git a/tests/components/twinkly/conftest.py b/tests/components/twinkly/conftest.py new file mode 100644 index 00000000000000..5a689c31baaca3 --- /dev/null +++ b/tests/components/twinkly/conftest.py @@ -0,0 +1,54 @@ +"""Configure tests for the Twinkly integration.""" +from collections.abc import Awaitable, Callable, Coroutine +from typing import Any +from unittest.mock import patch + +import pytest + +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from . import TEST_MODEL, TEST_NAME, TEST_UID, ClientMock + +from tests.common import MockConfigEntry + +ComponentSetup = Callable[[], Awaitable[ClientMock]] + +DOMAIN = "twinkly" +TITLE = "Twinkly" + + +@pytest.fixture(name="config_entry") +def mock_config_entry() -> MockConfigEntry: + """Create Twinkly entry in Home Assistant.""" + client = ClientMock() + return MockConfigEntry( + domain=DOMAIN, + title=TITLE, + unique_id=TEST_UID, + entry_id=TEST_UID, + data={ + "host": client.host, + "id": client.id, + "name": TEST_NAME, + "model": TEST_MODEL, + "device_name": TEST_NAME, + }, + ) + + +@pytest.fixture(name="setup_integration") +async def mock_setup_integration( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> Callable[[], Coroutine[Any, Any, ClientMock]]: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + async def func() -> ClientMock: + mock = ClientMock() + with patch("homeassistant.components.twinkly.Twinkly", return_value=mock): + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + return mock + + return func diff --git a/tests/components/twinkly/snapshots/test_diagnostics.ambr b/tests/components/twinkly/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000000..c5788444845593 --- /dev/null +++ b/tests/components/twinkly/snapshots/test_diagnostics.ambr @@ -0,0 +1,43 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'attributes': dict({ + 'brightness': 26, + 'color_mode': 'brightness', + 'effect_list': list([ + ]), + 'friendly_name': 'twinkly_test_device_name', + 'icon': 'mdi:string-lights', + 'supported_color_modes': list([ + 'brightness', + ]), + 'supported_features': 4, + }), + 'device_info': dict({ + 'device_name': 'twinkly_test_device_name', + 'product_code': 'twinkly_test_device_model', + 'sw_version': '2.8.10', + 'uuid': '4c8fccf5-e08a-4173-92d5-49bf479252a2', + }), + 'entry': dict({ + 'data': dict({ + 'device_name': 'twinkly_test_device_name', + 'host': '**REDACTED**', + 'id': '4c8fccf5-e08a-4173-92d5-49bf479252a2', + 'model': 'twinkly_test_device_model', + 'name': 'twinkly_test_device_name', + }), + 'disabled_by': None, + 'domain': 'twinkly', + 'entry_id': '4c8fccf5-e08a-4173-92d5-49bf479252a2', + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'Twinkly', + 'unique_id': '4c8fccf5-e08a-4173-92d5-49bf479252a2', + 'version': 1, + }), + }) +# --- diff --git a/tests/components/twinkly/test_config_flow.py b/tests/components/twinkly/test_config_flow.py index 1219130c1976f8..2d335c69923142 100644 --- a/tests/components/twinkly/test_config_flow.py +++ b/tests/components/twinkly/test_config_flow.py @@ -12,7 +12,7 @@ from homeassistant.const import CONF_MODEL from homeassistant.core import HomeAssistant -from . import TEST_MODEL, ClientMock +from . import TEST_MODEL, TEST_NAME, ClientMock from tests.common import MockConfigEntry @@ -60,11 +60,11 @@ async def test_success_flow(hass: HomeAssistant) -> None: ) assert result["type"] == "create_entry" - assert result["title"] == client.id + assert result["title"] == TEST_NAME assert result["data"] == { CONF_HOST: "dummy", CONF_ID: client.id, - CONF_NAME: client.id, + CONF_NAME: TEST_NAME, CONF_MODEL: TEST_MODEL, } @@ -113,11 +113,11 @@ async def test_dhcp_success(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) assert result["type"] == "create_entry" - assert result["title"] == client.id + assert result["title"] == TEST_NAME assert result["data"] == { CONF_HOST: "1.2.3.4", CONF_ID: client.id, - CONF_NAME: client.id, + CONF_NAME: TEST_NAME, CONF_MODEL: TEST_MODEL, } @@ -131,7 +131,7 @@ async def test_dhcp_already_exists(hass: HomeAssistant) -> None: data={ CONF_HOST: "1.2.3.4", CONF_ID: client.id, - CONF_NAME: client.id, + CONF_NAME: TEST_NAME, CONF_MODEL: TEST_MODEL, }, unique_id=client.id, diff --git a/tests/components/twinkly/test_diagnostics.py b/tests/components/twinkly/test_diagnostics.py new file mode 100644 index 00000000000000..ab07cabef4ab1d --- /dev/null +++ b/tests/components/twinkly/test_diagnostics.py @@ -0,0 +1,28 @@ +"""Tests for the diagnostics of the twinkly component.""" +from collections.abc import Awaitable, Callable + +from syrupy import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from . import ClientMock + +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + +ComponentSetup = Callable[[], Awaitable[ClientMock]] + +DOMAIN = "twinkly" + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + setup_integration: ComponentSetup, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + await setup_integration() + entry = hass.config_entries.async_entries(DOMAIN)[0] + + assert await get_diagnostics_for_config_entry(hass, hass_client, entry) == snapshot diff --git a/tests/components/twinkly/test_light.py b/tests/components/twinkly/test_light.py index f66c82dc2eda34..bcb40f22d08a27 100644 --- a/tests/components/twinkly/test_light.py +++ b/tests/components/twinkly/test_light.py @@ -16,7 +16,7 @@ from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.entity_registry import RegistryEntry -from . import TEST_MODEL, TEST_NAME_ORIGINAL, ClientMock +from . import TEST_MODEL, TEST_NAME, TEST_NAME_ORIGINAL, ClientMock from tests.common import MockConfigEntry @@ -28,16 +28,16 @@ async def test_initial_state(hass: HomeAssistant) -> None: state = hass.states.get(entity.entity_id) # Basic state properties - assert state.name == entity.unique_id + assert state.name == TEST_NAME assert state.state == "on" assert state.attributes[ATTR_BRIGHTNESS] == 26 - assert state.attributes["friendly_name"] == entity.unique_id + assert state.attributes["friendly_name"] == TEST_NAME assert state.attributes["icon"] == "mdi:string-lights" - assert entity.original_name == entity.unique_id + assert entity.original_name == TEST_NAME assert entity.original_icon == "mdi:string-lights" - assert device.name == entity.unique_id + assert device.name == TEST_NAME assert device.model == TEST_MODEL assert device.manufacturer == "LEDWORKS" diff --git a/tests/components/unifi/test_device_tracker.py b/tests/components/unifi/test_device_tracker.py index 7b939077e48376..99874b3a949f15 100644 --- a/tests/components/unifi/test_device_tracker.py +++ b/tests/components/unifi/test_device_tracker.py @@ -946,7 +946,7 @@ async def test_restoring_client( await setup_unifi_integration( hass, aioclient_mock, - options={CONF_BLOCK_CLIENT: True}, + options={CONF_BLOCK_CLIENT: [restored["mac"]]}, clients_response=[client], clients_all_response=[restored, not_restored], ) diff --git a/tests/components/unifi/test_sensor.py b/tests/components/unifi/test_sensor.py index da2c0b46f763ec..7b6a3bc1edc6a7 100644 --- a/tests/components/unifi/test_sensor.py +++ b/tests/components/unifi/test_sensor.py @@ -336,8 +336,8 @@ async def test_bandwidth_sensors( "mac": "00:00:00:00:00:02", "name": "Wireless client", "oui": "Producer", - "rx_bytes-r": 2345000000, - "tx_bytes-r": 6789000000, + "rx_bytes-r": 2345000000.0, + "tx_bytes-r": 6789000000.0, } options = { CONF_ALLOW_BANDWIDTH_SENSORS: True, @@ -566,7 +566,7 @@ async def test_poe_port_switches( ) -> None: """Test the update_items function with some clients.""" await setup_unifi_integration(hass, aioclient_mock, devices_response=[DEVICE_1]) - assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 0 + assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 1 ent_reg = er.async_get(hass) ent_reg_entry = ent_reg.async_get("sensor.mock_name_port_1_poe_power") @@ -788,8 +788,8 @@ async def test_outlet_power_readings( """Test the outlet power reporting on PDU devices.""" await setup_unifi_integration(hass, aioclient_mock, devices_response=[PDU_DEVICE_1]) - assert len(hass.states.async_all()) == 9 - assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 3 + assert len(hass.states.async_all()) == 10 + assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 4 ent_reg = er.async_get(hass) ent_reg_entry = ent_reg.async_get(f"sensor.{entity_id}") @@ -809,3 +809,104 @@ async def test_outlet_power_readings( sensor_data = hass.states.get(f"sensor.{entity_id}") assert sensor_data.state == expected_update_value + + +async def test_device_uptime( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_unifi_websocket +) -> None: + """Verify that uptime sensors are working as expected.""" + device = { + "board_rev": 3, + "device_id": "mock-id", + "has_fan": True, + "fan_level": 0, + "ip": "10.0.1.1", + "last_seen": 1562600145, + "mac": "00:00:00:00:01:01", + "model": "US16P150", + "name": "Device", + "next_interval": 20, + "overheating": True, + "state": 1, + "type": "usw", + "upgradable": True, + "uptime": 60, + "version": "4.0.42.10433", + } + + now = datetime(2021, 1, 1, 1, 1, 0, tzinfo=dt_util.UTC) + with patch("homeassistant.util.dt.now", return_value=now): + await setup_unifi_integration(hass, aioclient_mock, devices_response=[device]) + + assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 1 + assert hass.states.get("sensor.device_uptime").state == "2021-01-01T01:00:00+00:00" + + ent_reg = er.async_get(hass) + assert ( + ent_reg.async_get("sensor.device_uptime").entity_category + is EntityCategory.DIAGNOSTIC + ) + + # Verify normal new event doesn't change uptime + # 4 seconds has passed + + device["uptime"] = 64 + now = datetime(2021, 1, 1, 1, 1, 4, tzinfo=dt_util.UTC) + with patch("homeassistant.util.dt.now", return_value=now): + mock_unifi_websocket(message=MessageKey.DEVICE, data=device) + await hass.async_block_till_done() + + assert hass.states.get("sensor.device_uptime").state == "2021-01-01T01:00:00+00:00" + + # Verify new event change uptime + # 1 month has passed + + device["uptime"] = 60 + now = datetime(2021, 2, 1, 1, 1, 0, tzinfo=dt_util.UTC) + with patch("homeassistant.util.dt.now", return_value=now): + mock_unifi_websocket(message=MessageKey.DEVICE, data=device) + await hass.async_block_till_done() + + assert hass.states.get("sensor.device_uptime").state == "2021-02-01T01:00:00+00:00" + + +async def test_device_temperature( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_unifi_websocket +) -> None: + """Verify that temperature sensors are working as expected.""" + device = { + "board_rev": 3, + "device_id": "mock-id", + "general_temperature": 30, + "has_fan": True, + "has_temperature": True, + "fan_level": 0, + "ip": "10.0.1.1", + "last_seen": 1562600145, + "mac": "00:00:00:00:01:01", + "model": "US16P150", + "name": "Device", + "next_interval": 20, + "overheating": True, + "state": 1, + "type": "usw", + "upgradable": True, + "uptime": 60, + "version": "4.0.42.10433", + } + + await setup_unifi_integration(hass, aioclient_mock, devices_response=[device]) + assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 2 + assert hass.states.get("sensor.device_temperature").state == "30" + + ent_reg = er.async_get(hass) + assert ( + ent_reg.async_get("sensor.device_temperature").entity_category + is EntityCategory.DIAGNOSTIC + ) + + # Verify new event change temperature + device["general_temperature"] = 60 + mock_unifi_websocket(message=MessageKey.DEVICE, data=device) + await hass.async_block_till_done() + assert hass.states.get("sensor.device_temperature").state == "60" diff --git a/tests/components/unifi/test_switch.py b/tests/components/unifi/test_switch.py index d376cab8add0a7..8e53611929115d 100644 --- a/tests/components/unifi/test_switch.py +++ b/tests/components/unifi/test_switch.py @@ -6,7 +6,6 @@ from aiounifi.websocket import WebsocketState import pytest -from homeassistant import config_entries from homeassistant.components.switch import ( DOMAIN as SWITCH_DOMAIN, SERVICE_TURN_OFF, @@ -34,12 +33,7 @@ from homeassistant.helpers.entity_registry import RegistryEntryDisabler from homeassistant.util import dt as dt_util -from .test_controller import ( - CONTROLLER_HOST, - ENTRY_CONFIG, - SITE, - setup_unifi_integration, -) +from .test_controller import CONTROLLER_HOST, SITE, setup_unifi_integration from tests.common import async_fire_time_changed from tests.test_util.aiohttp import AiohttpClientMocker @@ -1436,38 +1430,6 @@ async def test_poe_port_switches( assert hass.states.get("switch.mock_name_port_1_poe").state == STATE_OFF -async def test_remove_poe_client_switches( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: - """Test old PoE client switches are removed.""" - - config_entry = config_entries.ConfigEntry( - version=1, - domain=UNIFI_DOMAIN, - title="Mock Title", - data=ENTRY_CONFIG, - source="test", - options={}, - entry_id="1", - ) - - ent_reg = er.async_get(hass) - ent_reg.async_get_or_create( - SWITCH_DOMAIN, - UNIFI_DOMAIN, - "poe-123", - config_entry=config_entry, - ) - - await setup_unifi_integration(hass, aioclient_mock) - - assert not [ - entry - for entry in ent_reg.entities.values() - if entry.config_entry_id == config_entry.entry_id - ] - - async def test_wlan_switches( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_unifi_websocket ) -> None: diff --git a/tests/components/utility_meter/test_sensor.py b/tests/components/utility_meter/test_sensor.py index b8f197a4dee21b..43d68d87362caa 100644 --- a/tests/components/utility_meter/test_sensor.py +++ b/tests/components/utility_meter/test_sensor.py @@ -1460,6 +1460,39 @@ def test_calculate_adjustment_invalid_new_state( assert "Invalid state unknown" in caplog.text +async def test_unit_of_measurement_missing_invalid_new_state( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that a suggestion is created when new_state is missing unit_of_measurement.""" + yaml_config = { + "utility_meter": { + "energy_bill": { + "source": "sensor.energy", + } + } + } + source_entity_id = yaml_config[DOMAIN]["energy_bill"]["source"] + + assert await async_setup_component(hass, DOMAIN, yaml_config) + await hass.async_block_till_done() + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + hass.states.async_set(source_entity_id, 4, {ATTR_UNIT_OF_MEASUREMENT: None}) + + await hass.async_block_till_done() + + state = hass.states.get("sensor.energy_bill") + assert state is not None + assert state.state == "0" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None + assert ( + f"Source sensor {source_entity_id} has no unit of measurement." in caplog.text + ) + + async def test_device_id(hass: HomeAssistant) -> None: """Test for source entity device for Utility Meter.""" device_registry = dr.async_get(hass) diff --git a/tests/components/venstar/__init__.py b/tests/components/venstar/__init__.py index fa35dd88379907..f91f8f28bdfb8b 100644 --- a/tests/components/venstar/__init__.py +++ b/tests/components/venstar/__init__.py @@ -18,7 +18,8 @@ def __init__( """Initialize the Venstar library.""" self.status = {} self.model = "COLORTOUCH" - self._api_ver = 5 + self._api_ver = 7 + self._firmware_ver = tuple(5, 28) self.name = "TestVenstar" self._info = {} self._sensors = {} diff --git a/tests/components/vizio/const.py b/tests/components/vizio/const.py index 119443962fced3..849c13d4396a56 100644 --- a/tests/components/vizio/const.py +++ b/tests/components/vizio/const.py @@ -1,4 +1,6 @@ """Constants for the Vizio integration tests.""" +from ipaddress import ip_address + from homeassistant.components import zeroconf from homeassistant.components.media_player import ( DOMAIN as MP_DOMAIN, @@ -197,8 +199,8 @@ def __init__(self, auth_token: str) -> None: ZEROCONF_PORT = HOST.split(":")[1] MOCK_ZEROCONF_SERVICE_INFO = zeroconf.ZeroconfServiceInfo( - host=ZEROCONF_HOST, - addresses=[ZEROCONF_HOST], + ip_address=ip_address(ZEROCONF_HOST), + ip_addresses=[ip_address(ZEROCONF_HOST)], hostname="mock_hostname", name=ZEROCONF_NAME, port=ZEROCONF_PORT, diff --git a/tests/components/vizio/test_config_flow.py b/tests/components/vizio/test_config_flow.py index 4c47a0c56408a6..578d79fcba0a57 100644 --- a/tests/components/vizio/test_config_flow.py +++ b/tests/components/vizio/test_config_flow.py @@ -801,8 +801,9 @@ async def test_zeroconf_flow_with_port_in_host( entry.add_to_hass(hass) # Try rediscovering same device, this time with port already in host + # This test needs to be refactored as the port is never in the host + # field of the zeroconf service info discovery_info = dataclasses.replace(MOCK_ZEROCONF_SERVICE_INFO) - discovery_info.host = f"{discovery_info.host}:{discovery_info.port}" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=discovery_info ) diff --git a/tests/components/vodafone_station/test_config_flow.py b/tests/components/vodafone_station/test_config_flow.py index 03a1198288d825..3d2ef0cf568621 100644 --- a/tests/components/vodafone_station/test_config_flow.py +++ b/tests/components/vodafone_station/test_config_flow.py @@ -78,7 +78,7 @@ async def test_exception_connection(hass: HomeAssistant, side_effect, error) -> # Should be recoverable after hits error with patch( - "homeassistant.components.vodafone_station.config_flow.VodafoneStationApi.get_all_devices", + "homeassistant.components.vodafone_station.config_flow.VodafoneStationApi.get_devices_data", return_value={ "wifi_user": "on|laptop|device-1|xx:xx:xx:xx:xx:xx|192.168.100.1||2.4G", "ethernet": "laptop|device-2|yy:yy:yy:yy:yy:yy|192.168.100.2|;", @@ -191,7 +191,7 @@ async def test_reauth_not_successful(hass: HomeAssistant, side_effect, error) -> # Should be recoverable after hits error with patch( - "homeassistant.components.vodafone_station.config_flow.VodafoneStationApi.get_all_devices", + "homeassistant.components.vodafone_station.config_flow.VodafoneStationApi.get_devices_data", return_value={ "wifi_user": "on|laptop|device-1|xx:xx:xx:xx:xx:xx|192.168.100.1||2.4G", "ethernet": "laptop|device-2|yy:yy:yy:yy:yy:yy|192.168.100.2|;", diff --git a/tests/components/volumio/test_config_flow.py b/tests/components/volumio/test_config_flow.py index 5d734d1b2d55b0..841b558eba38f9 100644 --- a/tests/components/volumio/test_config_flow.py +++ b/tests/components/volumio/test_config_flow.py @@ -1,4 +1,5 @@ """Test the Volumio config flow.""" +from ipaddress import ip_address from unittest.mock import patch from homeassistant import config_entries @@ -19,8 +20,8 @@ TEST_DISCOVERY = zeroconf.ZeroconfServiceInfo( - host="1.1.1.1", - addresses=["1.1.1.1"], + ip_address=ip_address("1.1.1.1"), + ip_addresses=[ip_address("1.1.1.1")], hostname="mock_hostname", name="mock_name", port=3000, diff --git a/tests/components/waqi/__init__.py b/tests/components/waqi/__init__.py new file mode 100644 index 00000000000000..b6f36680ee368b --- /dev/null +++ b/tests/components/waqi/__init__.py @@ -0,0 +1 @@ +"""Tests for the World Air Quality Index (WAQI) integration.""" diff --git a/tests/components/waqi/conftest.py b/tests/components/waqi/conftest.py new file mode 100644 index 00000000000000..176c1e27d8f78d --- /dev/null +++ b/tests/components/waqi/conftest.py @@ -0,0 +1,30 @@ +"""Common fixtures for the World Air Quality Index (WAQI) tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.waqi.const import CONF_STATION_NUMBER, DOMAIN +from homeassistant.const import CONF_API_KEY + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.waqi.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id="4584", + title="de Jongweg, Utrecht", + data={CONF_API_KEY: "asd", CONF_STATION_NUMBER: 4584}, + ) diff --git a/tests/components/waqi/fixtures/air_quality_sensor.json b/tests/components/waqi/fixtures/air_quality_sensor.json new file mode 100644 index 00000000000000..49f1184822fe7b --- /dev/null +++ b/tests/components/waqi/fixtures/air_quality_sensor.json @@ -0,0 +1,160 @@ +{ + "aqi": 29, + "idx": 4584, + "attributions": [ + { + "url": "http://www.luchtmeetnet.nl/", + "name": "RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit", + "logo": "Netherland-RIVM.png" + }, + { + "url": "https://waqi.info/", + "name": "World Air Quality Index Project" + } + ], + "city": { + "geo": [52.105031, 5.124464], + "name": "de Jongweg, Utrecht", + "url": "https://aqicn.org/city/netherland/utrecht/de-jongweg", + "location": "" + }, + "dominentpol": "o3", + "iaqi": { + "h": { + "v": 80 + }, + "no2": { + "v": 2.3 + }, + "o3": { + "v": 29.4 + }, + "p": { + "v": 1008.8 + }, + "pm10": { + "v": 12 + }, + "pm25": { + "v": 17 + }, + "t": { + "v": 16 + }, + "w": { + "v": 1.4 + }, + "wg": { + "v": 2.4 + } + }, + "time": { + "s": "2023-08-07 17:00:00", + "tz": "+02:00", + "v": 1691427600, + "iso": "2023-08-07T17:00:00+02:00" + }, + "forecast": { + "daily": { + "o3": [ + { + "avg": 28, + "day": "2023-08-07", + "max": 34, + "min": 25 + }, + { + "avg": 22, + "day": "2023-08-08", + "max": 29, + "min": 19 + }, + { + "avg": 23, + "day": "2023-08-09", + "max": 35, + "min": 9 + }, + { + "avg": 18, + "day": "2023-08-10", + "max": 38, + "min": 3 + }, + { + "avg": 17, + "day": "2023-08-11", + "max": 17, + "min": 11 + } + ], + "pm10": [ + { + "avg": 8, + "day": "2023-08-07", + "max": 10, + "min": 6 + }, + { + "avg": 9, + "day": "2023-08-08", + "max": 12, + "min": 6 + }, + { + "avg": 9, + "day": "2023-08-09", + "max": 13, + "min": 6 + }, + { + "avg": 23, + "day": "2023-08-10", + "max": 33, + "min": 10 + }, + { + "avg": 27, + "day": "2023-08-11", + "max": 34, + "min": 27 + } + ], + "pm25": [ + { + "avg": 19, + "day": "2023-08-07", + "max": 29, + "min": 11 + }, + { + "avg": 25, + "day": "2023-08-08", + "max": 37, + "min": 19 + }, + { + "avg": 27, + "day": "2023-08-09", + "max": 45, + "min": 19 + }, + { + "avg": 64, + "day": "2023-08-10", + "max": 86, + "min": 33 + }, + { + "avg": 72, + "day": "2023-08-11", + "max": 89, + "min": 72 + } + ] + } + }, + "debug": { + "sync": "2023-08-08T01:29:52+09:00" + } +} diff --git a/tests/components/waqi/fixtures/search_result.json b/tests/components/waqi/fixtures/search_result.json new file mode 100644 index 00000000000000..65da5abc09a5a7 --- /dev/null +++ b/tests/components/waqi/fixtures/search_result.json @@ -0,0 +1,32 @@ +[ + { + "uid": 6332, + "aqi": "27", + "time": { + "tz": "+02:00", + "stime": "2023-08-08 15:00:00", + "vtime": 1691499600 + }, + "station": { + "name": "Griftpark, Utrecht", + "geo": [52.101308, 5.128183], + "url": "netherland/utrecht/griftpark", + "country": "NL" + } + }, + { + "uid": 4584, + "aqi": "27", + "time": { + "tz": "+02:00", + "stime": "2023-08-08 15:00:00", + "vtime": 1691499600 + }, + "station": { + "name": "de Jongweg, Utrecht", + "geo": [52.105031, 5.124464], + "url": "netherland/utrecht/de-jongweg", + "country": "NL" + } + } +] diff --git a/tests/components/waqi/test_config_flow.py b/tests/components/waqi/test_config_flow.py new file mode 100644 index 00000000000000..3901ffad550eea --- /dev/null +++ b/tests/components/waqi/test_config_flow.py @@ -0,0 +1,108 @@ +"""Test the World Air Quality Index (WAQI) config flow.""" +import json +from unittest.mock import AsyncMock, patch + +from aiowaqi import WAQIAirQuality, WAQIAuthenticationError, WAQIConnectionError +import pytest + +from homeassistant import config_entries +from homeassistant.components.waqi.const import CONF_STATION_NUMBER, DOMAIN +from homeassistant.const import ( + CONF_API_KEY, + CONF_LATITUDE, + CONF_LOCATION, + CONF_LONGITUDE, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import load_fixture + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +async def test_full_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + + with patch( + "aiowaqi.WAQIClient.authenticate", + ), patch( + "aiowaqi.WAQIClient.get_by_coordinates", + return_value=WAQIAirQuality.parse_obj( + json.loads(load_fixture("waqi/air_quality_sensor.json")) + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_LOCATION: {CONF_LATITUDE: 50.0, CONF_LONGITUDE: 10.0}, + CONF_API_KEY: "asd", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "de Jongweg, Utrecht" + assert result["data"] == { + CONF_API_KEY: "asd", + CONF_STATION_NUMBER: 4584, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (WAQIAuthenticationError(), "invalid_auth"), + (WAQIConnectionError(), "cannot_connect"), + (Exception(), "unknown"), + ], +) +async def test_flow_errors( + hass: HomeAssistant, exception: Exception, error: str +) -> None: + """Test we handle errors during configuration.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "aiowaqi.WAQIClient.authenticate", + ), patch( + "aiowaqi.WAQIClient.get_by_coordinates", + side_effect=exception, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_LOCATION: {CONF_LATITUDE: 50.0, CONF_LONGITUDE: 10.0}, + CONF_API_KEY: "asd", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": error} + + with patch( + "aiowaqi.WAQIClient.authenticate", + ), patch( + "aiowaqi.WAQIClient.get_by_coordinates", + return_value=WAQIAirQuality.parse_obj( + json.loads(load_fixture("waqi/air_quality_sensor.json")) + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_LOCATION: {CONF_LATITUDE: 50.0, CONF_LONGITUDE: 10.0}, + CONF_API_KEY: "asd", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY diff --git a/tests/components/waqi/test_sensor.py b/tests/components/waqi/test_sensor.py new file mode 100644 index 00000000000000..18f77028a29d18 --- /dev/null +++ b/tests/components/waqi/test_sensor.py @@ -0,0 +1,124 @@ +"""Test the World Air Quality Index (WAQI) sensor.""" +import json +from unittest.mock import patch + +from aiowaqi import WAQIAirQuality, WAQIError, WAQISearchResult + +from homeassistant.components.waqi.const import CONF_STATION_NUMBER, DOMAIN +from homeassistant.components.waqi.sensor import CONF_LOCATIONS, CONF_STATIONS +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntryState +from homeassistant.const import ( + CONF_API_KEY, + CONF_NAME, + CONF_PLATFORM, + CONF_TOKEN, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry, load_fixture + +LEGACY_CONFIG = { + Platform.SENSOR: [ + { + CONF_PLATFORM: DOMAIN, + CONF_TOKEN: "asd", + CONF_LOCATIONS: ["utrecht"], + CONF_STATIONS: [6332], + } + ] +} + + +async def test_legacy_migration(hass: HomeAssistant) -> None: + """Test migration from yaml to config flow.""" + search_result_json = json.loads(load_fixture("waqi/search_result.json")) + search_results = [ + WAQISearchResult.parse_obj(search_result) + for search_result in search_result_json + ] + with patch( + "aiowaqi.WAQIClient.search", + return_value=search_results, + ), patch( + "aiowaqi.WAQIClient.get_by_station_number", + return_value=WAQIAirQuality.parse_obj( + json.loads(load_fixture("waqi/air_quality_sensor.json")) + ), + ): + assert await async_setup_component(hass, Platform.SENSOR, LEGACY_CONFIG) + await hass.async_block_till_done() + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.LOADED + issue_registry = ir.async_get(hass) + assert len(issue_registry.issues) == 1 + + +async def test_legacy_migration_already_imported( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test migration from yaml to config flow after already imported.""" + mock_config_entry.add_to_hass(hass) + with patch( + "aiowaqi.WAQIClient.get_by_station_number", + return_value=WAQIAirQuality.parse_obj( + json.loads(load_fixture("waqi/air_quality_sensor.json")) + ), + ): + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + state = hass.states.get("sensor.waqi_de_jongweg_utrecht") + assert state.state == "29" + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_STATION_NUMBER: 4584, + CONF_NAME: "xyz", + CONF_API_KEY: "asd", + }, + ) + ) + await hass.async_block_till_done() + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.LOADED + issue_registry = ir.async_get(hass) + assert len(issue_registry.issues) == 1 + + +async def test_sensor(hass: HomeAssistant, mock_config_entry: MockConfigEntry) -> None: + """Test failed update.""" + mock_config_entry.add_to_hass(hass) + with patch( + "aiowaqi.WAQIClient.get_by_station_number", + return_value=WAQIAirQuality.parse_obj( + json.loads(load_fixture("waqi/air_quality_sensor.json")) + ), + ): + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + state = hass.states.get("sensor.waqi_de_jongweg_utrecht") + assert state.state == "29" + + +async def test_updating_failed( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test failed update.""" + mock_config_entry.add_to_hass(hass) + with patch( + "aiowaqi.WAQIClient.get_by_station_number", + side_effect=WAQIError(), + ): + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + assert mock_config_entry.state == ConfigEntryState.SETUP_RETRY diff --git a/tests/components/weatherkit/__init__.py b/tests/components/weatherkit/__init__.py new file mode 100644 index 00000000000000..5118c44c45be80 --- /dev/null +++ b/tests/components/weatherkit/__init__.py @@ -0,0 +1,71 @@ +"""Tests for the Apple WeatherKit integration.""" +from unittest.mock import patch + +from apple_weatherkit import DataSetType + +from homeassistant.components.weatherkit.const import ( + CONF_KEY_ID, + CONF_KEY_PEM, + CONF_SERVICE_ID, + CONF_TEAM_ID, + DOMAIN, +) +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, load_json_object_fixture + +EXAMPLE_CONFIG_DATA = { + CONF_LATITUDE: 35.4690101707532, + CONF_LONGITUDE: 135.74817234593166, + CONF_KEY_ID: "QABCDEFG123", + CONF_SERVICE_ID: "io.home-assistant.testing", + CONF_TEAM_ID: "ABCD123456", + CONF_KEY_PEM: "-----BEGIN PRIVATE KEY-----\nwhateverkey\n-----END PRIVATE KEY-----", +} + + +async def init_integration( + hass: HomeAssistant, + is_night_time: bool = False, + has_hourly_forecast: bool = True, + has_daily_forecast: bool = True, +) -> MockConfigEntry: + """Set up the WeatherKit integration in Home Assistant.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="Home", + unique_id="0123456", + data=EXAMPLE_CONFIG_DATA, + ) + + weather_response = load_json_object_fixture("weatherkit/weather_response.json") + + available_data_sets = [DataSetType.CURRENT_WEATHER] + + if is_night_time: + weather_response["currentWeather"]["daylight"] = False + weather_response["currentWeather"]["conditionCode"] = "Clear" + + if not has_daily_forecast: + del weather_response["forecastDaily"] + else: + available_data_sets.append(DataSetType.DAILY_FORECAST) + + if not has_hourly_forecast: + del weather_response["forecastHourly"] + else: + available_data_sets.append(DataSetType.HOURLY_FORECAST) + + with patch( + "homeassistant.components.weatherkit.WeatherKitApiClient.get_weather_data", + return_value=weather_response, + ), patch( + "homeassistant.components.weatherkit.WeatherKitApiClient.get_availability", + return_value=available_data_sets, + ): + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + return entry diff --git a/tests/components/weatherkit/conftest.py b/tests/components/weatherkit/conftest.py new file mode 100644 index 00000000000000..7cfa2f7eef537a --- /dev/null +++ b/tests/components/weatherkit/conftest.py @@ -0,0 +1,14 @@ +"""Common fixtures for the Apple WeatherKit tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.weatherkit.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/weatherkit/fixtures/weather_response.json b/tests/components/weatherkit/fixtures/weather_response.json new file mode 100644 index 00000000000000..c2d619d85d8c97 --- /dev/null +++ b/tests/components/weatherkit/fixtures/weather_response.json @@ -0,0 +1,6344 @@ +{ + "currentWeather": { + "name": "CurrentWeather", + "metadata": { + "attributionURL": "https://developer.apple.com/weatherkit/data-source-attribution/", + "expireTime": "2023-09-08T22:08:04Z", + "latitude": 35.47, + "longitude": 135.749, + "readTime": "2023-09-08T22:03:04Z", + "reportedTime": "2023-09-08T21:02:40Z", + "units": "m", + "version": 1 + }, + "asOf": "2023-09-08T22:03:04Z", + "cloudCover": 0.62, + "cloudCoverLowAltPct": 0.35, + "cloudCoverMidAltPct": 0.22, + "cloudCoverHighAltPct": 0.32, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.91, + "precipitationIntensity": 0.0, + "pressure": 1009.8, + "pressureTrend": "rising", + "temperature": 22.9, + "temperatureApparent": 24.92, + "temperatureDewPoint": 21.28, + "uvIndex": 1, + "visibility": 20965.22, + "windDirection": 259, + "windGust": 10.53, + "windSpeed": 5.23 + }, + "forecastDaily": { + "name": "DailyForecast", + "metadata": { + "attributionURL": "https://developer.apple.com/weatherkit/data-source-attribution/", + "expireTime": "2023-09-08T23:03:04Z", + "latitude": 35.47, + "longitude": 135.749, + "readTime": "2023-09-08T22:03:04Z", + "reportedTime": "2023-09-08T21:02:40Z", + "units": "m", + "version": 1 + }, + "days": [ + { + "forecastStart": "2023-09-08T15:00:00Z", + "forecastEnd": "2023-09-09T15:00:00Z", + "conditionCode": "MostlyCloudy", + "maxUvIndex": 6, + "moonPhase": "waningCrescent", + "moonset": "2023-09-09T06:10:26Z", + "precipitationAmount": 0.0, + "precipitationAmountByType": {}, + "precipitationChance": 0.0, + "precipitationType": "clear", + "snowfallAmount": 0.0, + "solarMidnight": "2023-09-09T14:54:36Z", + "solarNoon": "2023-09-09T02:54:26Z", + "sunrise": "2023-09-08T20:34:47Z", + "sunriseCivil": "2023-09-08T20:09:00Z", + "sunriseNautical": "2023-09-08T19:38:47Z", + "sunriseAstronomical": "2023-09-08T19:07:36Z", + "sunset": "2023-09-09T09:13:58Z", + "sunsetCivil": "2023-09-09T09:39:40Z", + "sunsetNautical": "2023-09-09T10:09:52Z", + "sunsetAstronomical": "2023-09-09T10:40:54Z", + "temperatureMax": 28.62, + "temperatureMin": 21.18, + "daytimeForecast": { + "forecastStart": "2023-09-08T22:00:00Z", + "forecastEnd": "2023-09-09T10:00:00Z", + "cloudCover": 0.75, + "conditionCode": "MostlyCloudy", + "humidity": 0.76, + "precipitationAmount": 0.0, + "precipitationAmountByType": {}, + "precipitationChance": 0.0, + "precipitationType": "clear", + "snowfallAmount": 0.0, + "windDirection": 318, + "windSpeed": 7.36 + }, + "overnightForecast": { + "forecastStart": "2023-09-09T10:00:00Z", + "forecastEnd": "2023-09-09T22:00:00Z", + "cloudCover": 0.57, + "conditionCode": "PartlyCloudy", + "humidity": 0.92, + "precipitationAmount": 0.0, + "precipitationAmountByType": {}, + "precipitationChance": 0.0, + "precipitationType": "clear", + "snowfallAmount": 0.0, + "windDirection": 166, + "windSpeed": 2.99 + }, + "restOfDayForecast": { + "forecastStart": "2023-09-08T22:03:04Z", + "forecastEnd": "2023-09-09T15:00:00Z", + "cloudCover": 0.69, + "conditionCode": "MostlyCloudy", + "humidity": 0.8, + "precipitationAmount": 0.0, + "precipitationAmountByType": {}, + "precipitationChance": 0.0, + "precipitationType": "clear", + "snowfallAmount": 0.0, + "windDirection": 315, + "windSpeed": 5.78 + } + }, + { + "forecastStart": "2023-09-09T15:00:00Z", + "forecastEnd": "2023-09-10T15:00:00Z", + "conditionCode": "Rain", + "maxUvIndex": 6, + "moonPhase": "waningCrescent", + "moonrise": "2023-09-09T15:36:16Z", + "moonset": "2023-09-10T06:54:57Z", + "precipitationAmount": 3.6, + "precipitationAmountByType": {}, + "precipitationChance": 0.45, + "precipitationType": "rain", + "snowfallAmount": 0.0, + "solarMidnight": "2023-09-10T14:54:15Z", + "solarNoon": "2023-09-10T02:54:05Z", + "sunrise": "2023-09-09T20:35:31Z", + "sunriseCivil": "2023-09-09T20:09:47Z", + "sunriseNautical": "2023-09-09T19:39:37Z", + "sunriseAstronomical": "2023-09-09T19:08:32Z", + "sunset": "2023-09-10T09:12:31Z", + "sunsetCivil": "2023-09-10T09:38:11Z", + "sunsetNautical": "2023-09-10T10:08:20Z", + "sunsetAstronomical": "2023-09-10T10:39:18Z", + "temperatureMax": 30.64, + "temperatureMin": 21.0, + "daytimeForecast": { + "forecastStart": "2023-09-09T22:00:00Z", + "forecastEnd": "2023-09-10T10:00:00Z", + "cloudCover": 0.76, + "conditionCode": "Rain", + "humidity": 0.73, + "precipitationAmount": 3.6, + "precipitationAmountByType": {}, + "precipitationChance": 0.35, + "precipitationType": "rain", + "snowfallAmount": 0.0, + "windDirection": 96, + "windSpeed": 4.94 + }, + "overnightForecast": { + "forecastStart": "2023-09-10T10:00:00Z", + "forecastEnd": "2023-09-10T22:00:00Z", + "cloudCover": 0.77, + "conditionCode": "MostlyCloudy", + "humidity": 0.88, + "precipitationAmount": 0.0, + "precipitationAmountByType": {}, + "precipitationChance": 0.0, + "precipitationType": "clear", + "snowfallAmount": 0.0, + "windDirection": 141, + "windSpeed": 7.84 + } + }, + { + "forecastStart": "2023-09-10T15:00:00Z", + "forecastEnd": "2023-09-11T15:00:00Z", + "conditionCode": "MostlyCloudy", + "maxUvIndex": 6, + "moonPhase": "waningCrescent", + "moonrise": "2023-09-10T16:34:55Z", + "moonset": "2023-09-11T07:32:40Z", + "precipitationAmount": 0.0, + "precipitationAmountByType": {}, + "precipitationChance": 0.0, + "precipitationType": "clear", + "snowfallAmount": 0.0, + "solarMidnight": "2023-09-11T14:53:54Z", + "solarNoon": "2023-09-11T02:53:44Z", + "sunrise": "2023-09-10T20:36:16Z", + "sunriseCivil": "2023-09-10T20:10:33Z", + "sunriseNautical": "2023-09-10T19:40:27Z", + "sunriseAstronomical": "2023-09-10T19:09:28Z", + "sunset": "2023-09-11T09:11:04Z", + "sunsetCivil": "2023-09-11T09:36:43Z", + "sunsetNautical": "2023-09-11T10:06:47Z", + "sunsetAstronomical": "2023-09-11T10:37:41Z", + "temperatureMax": 30.44, + "temperatureMin": 23.14, + "daytimeForecast": { + "forecastStart": "2023-09-10T22:00:00Z", + "forecastEnd": "2023-09-11T10:00:00Z", + "cloudCover": 0.66, + "conditionCode": "MostlyCloudy", + "humidity": 0.69, + "precipitationAmount": 0.0, + "precipitationAmountByType": {}, + "precipitationChance": 0.0, + "precipitationType": "clear", + "snowfallAmount": 0.0, + "windDirection": 139, + "windSpeed": 14.23 + }, + "overnightForecast": { + "forecastStart": "2023-09-11T10:00:00Z", + "forecastEnd": "2023-09-11T22:00:00Z", + "cloudCover": 0.83, + "conditionCode": "MostlyCloudy", + "humidity": 0.85, + "precipitationAmount": 0.5, + "precipitationAmountByType": {}, + "precipitationChance": 0.22, + "precipitationType": "rain", + "snowfallAmount": 0.0, + "windDirection": 144, + "windSpeed": 11.26 + } + }, + { + "forecastStart": "2023-09-11T15:00:00Z", + "forecastEnd": "2023-09-12T15:00:00Z", + "conditionCode": "Drizzle", + "maxUvIndex": 5, + "moonPhase": "waningCrescent", + "moonrise": "2023-09-11T17:34:35Z", + "moonset": "2023-09-12T08:04:36Z", + "precipitationAmount": 0.7, + "precipitationAmountByType": {}, + "precipitationChance": 0.47, + "precipitationType": "rain", + "snowfallAmount": 0.0, + "solarMidnight": "2023-09-12T14:53:33Z", + "solarNoon": "2023-09-12T02:53:22Z", + "sunrise": "2023-09-11T20:37:00Z", + "sunriseCivil": "2023-09-11T20:11:20Z", + "sunriseNautical": "2023-09-11T19:41:16Z", + "sunriseAstronomical": "2023-09-11T19:10:23Z", + "sunset": "2023-09-12T09:09:37Z", + "sunsetCivil": "2023-09-12T09:35:14Z", + "sunsetNautical": "2023-09-12T10:05:15Z", + "sunsetAstronomical": "2023-09-12T10:36:04Z", + "temperatureMax": 30.42, + "temperatureMin": 23.15, + "daytimeForecast": { + "forecastStart": "2023-09-11T22:00:00Z", + "forecastEnd": "2023-09-12T10:00:00Z", + "cloudCover": 0.68, + "conditionCode": "Drizzle", + "humidity": 0.72, + "precipitationAmount": 0.2, + "precipitationAmountByType": {}, + "precipitationChance": 0.32, + "precipitationType": "rain", + "snowfallAmount": 0.0, + "windDirection": 140, + "windSpeed": 12.44 + }, + "overnightForecast": { + "forecastStart": "2023-09-12T10:00:00Z", + "forecastEnd": "2023-09-12T22:00:00Z", + "cloudCover": 0.7, + "conditionCode": "MostlyCloudy", + "humidity": 0.86, + "precipitationAmount": 0.0, + "precipitationAmountByType": {}, + "precipitationChance": 0.47, + "precipitationType": "clear", + "snowfallAmount": 0.0, + "windDirection": 148, + "windSpeed": 8.78 + } + }, + { + "forecastStart": "2023-09-12T15:00:00Z", + "forecastEnd": "2023-09-13T15:00:00Z", + "conditionCode": "Rain", + "maxUvIndex": 6, + "moonPhase": "new", + "moonrise": "2023-09-12T18:33:48Z", + "moonset": "2023-09-13T08:32:25Z", + "precipitationAmount": 7.7, + "precipitationAmountByType": {}, + "precipitationChance": 0.37, + "precipitationType": "rain", + "snowfallAmount": 0.0, + "solarMidnight": "2023-09-13T14:53:11Z", + "solarNoon": "2023-09-13T02:53:01Z", + "sunrise": "2023-09-12T20:37:45Z", + "sunriseCivil": "2023-09-12T20:12:07Z", + "sunriseNautical": "2023-09-12T19:42:05Z", + "sunriseAstronomical": "2023-09-12T19:11:18Z", + "sunset": "2023-09-13T09:08:10Z", + "sunsetCivil": "2023-09-13T09:33:46Z", + "sunsetNautical": "2023-09-13T10:03:43Z", + "sunsetAstronomical": "2023-09-13T10:34:27Z", + "temperatureMax": 30.4, + "temperatureMin": 22.15, + "daytimeForecast": { + "forecastStart": "2023-09-12T22:00:00Z", + "forecastEnd": "2023-09-13T10:00:00Z", + "cloudCover": 0.71, + "conditionCode": "MostlyCloudy", + "humidity": 0.7, + "precipitationAmount": 7.7, + "precipitationAmountByType": {}, + "precipitationChance": 0.24, + "precipitationType": "rain", + "snowfallAmount": 0.0, + "windDirection": 70, + "windSpeed": 7.79 + }, + "overnightForecast": { + "forecastStart": "2023-09-13T10:00:00Z", + "forecastEnd": "2023-09-13T22:00:00Z", + "cloudCover": 0.86, + "conditionCode": "MostlyCloudy", + "humidity": 0.91, + "precipitationAmount": 0.0, + "precipitationAmountByType": {}, + "precipitationChance": 0.0, + "precipitationType": "clear", + "snowfallAmount": 0.0, + "windDirection": 151, + "windSpeed": 5.69 + } + }, + { + "forecastStart": "2023-09-13T15:00:00Z", + "forecastEnd": "2023-09-14T15:00:00Z", + "conditionCode": "Drizzle", + "maxUvIndex": 6, + "moonPhase": "new", + "moonrise": "2023-09-13T19:31:58Z", + "moonset": "2023-09-14T08:57:12Z", + "precipitationAmount": 0.6, + "precipitationAmountByType": {}, + "precipitationChance": 0.45, + "precipitationType": "rain", + "snowfallAmount": 0.0, + "solarMidnight": "2023-09-14T14:52:50Z", + "solarNoon": "2023-09-14T02:52:40Z", + "sunrise": "2023-09-13T20:38:29Z", + "sunriseCivil": "2023-09-13T20:12:53Z", + "sunriseNautical": "2023-09-13T19:42:55Z", + "sunriseAstronomical": "2023-09-13T19:12:12Z", + "sunset": "2023-09-14T09:06:42Z", + "sunsetCivil": "2023-09-14T09:32:17Z", + "sunsetNautical": "2023-09-14T10:02:11Z", + "sunsetAstronomical": "2023-09-14T10:32:51Z", + "temperatureMax": 30.98, + "temperatureMin": 22.62, + "daytimeForecast": { + "forecastStart": "2023-09-13T22:00:00Z", + "forecastEnd": "2023-09-14T10:00:00Z", + "cloudCover": 0.54, + "conditionCode": "PartlyCloudy", + "humidity": 0.71, + "precipitationAmount": 0.6, + "precipitationAmountByType": {}, + "precipitationChance": 0.45, + "precipitationType": "rain", + "snowfallAmount": 0.0, + "windDirection": 11, + "windSpeed": 5.37 + }, + "overnightForecast": { + "forecastStart": "2023-09-14T10:00:00Z", + "forecastEnd": "2023-09-14T22:00:00Z", + "cloudCover": 0.35, + "conditionCode": "MostlyClear", + "humidity": 0.89, + "precipitationAmount": 0.0, + "precipitationAmountByType": {}, + "precipitationChance": 0.52, + "precipitationType": "clear", + "snowfallAmount": 0.0, + "windDirection": 166, + "windSpeed": 5.09 + } + }, + { + "forecastStart": "2023-09-14T15:00:00Z", + "forecastEnd": "2023-09-15T15:00:00Z", + "conditionCode": "PartlyCloudy", + "maxUvIndex": 7, + "moonPhase": "new", + "moonrise": "2023-09-14T20:29:10Z", + "moonset": "2023-09-15T09:20:27Z", + "precipitationAmount": 0.0, + "precipitationAmountByType": {}, + "precipitationChance": 0.52, + "precipitationType": "clear", + "snowfallAmount": 0.0, + "solarMidnight": "2023-09-15T14:52:28Z", + "solarNoon": "2023-09-15T02:52:18Z", + "sunrise": "2023-09-14T20:39:14Z", + "sunriseCivil": "2023-09-14T20:13:39Z", + "sunriseNautical": "2023-09-14T19:43:43Z", + "sunriseAstronomical": "2023-09-14T19:13:06Z", + "sunset": "2023-09-15T09:05:15Z", + "sunsetCivil": "2023-09-15T09:30:48Z", + "sunsetNautical": "2023-09-15T10:00:39Z", + "sunsetAstronomical": "2023-09-15T10:31:15Z", + "temperatureMax": 31.47, + "temperatureMin": 22.4, + "daytimeForecast": { + "forecastStart": "2023-09-14T22:00:00Z", + "forecastEnd": "2023-09-15T10:00:00Z", + "cloudCover": 0.39, + "conditionCode": "PartlyCloudy", + "humidity": 0.69, + "precipitationAmount": 0.0, + "precipitationAmountByType": {}, + "precipitationChance": 0.29, + "precipitationType": "clear", + "snowfallAmount": 0.0, + "windDirection": 356, + "windSpeed": 7.68 + }, + "overnightForecast": { + "forecastStart": "2023-09-15T10:00:00Z", + "forecastEnd": "2023-09-15T22:00:00Z", + "cloudCover": 0.61, + "conditionCode": "PartlyCloudy", + "humidity": 0.89, + "precipitationAmount": 0.0, + "precipitationAmountByType": {}, + "precipitationChance": 0.0, + "precipitationType": "clear", + "snowfallAmount": 0.0, + "windDirection": 179, + "windSpeed": 5.46 + } + }, + { + "forecastStart": "2023-09-15T15:00:00Z", + "forecastEnd": "2023-09-16T15:00:00Z", + "conditionCode": "MostlyClear", + "maxUvIndex": 8, + "moonPhase": "waxingCrescent", + "moonrise": "2023-09-15T21:26:00Z", + "moonset": "2023-09-16T09:43:08Z", + "precipitationAmount": 0.0, + "precipitationAmountByType": {}, + "precipitationChance": 0.0, + "precipitationType": "clear", + "snowfallAmount": 0.0, + "solarMidnight": "2023-09-16T14:52:07Z", + "solarNoon": "2023-09-16T02:51:57Z", + "sunrise": "2023-09-15T20:39:59Z", + "sunriseCivil": "2023-09-15T20:14:26Z", + "sunriseNautical": "2023-09-15T19:44:32Z", + "sunriseAstronomical": "2023-09-15T19:13:59Z", + "sunset": "2023-09-16T09:03:47Z", + "sunsetCivil": "2023-09-16T09:29:19Z", + "sunsetNautical": "2023-09-16T09:59:07Z", + "sunsetAstronomical": "2023-09-16T10:29:39Z", + "temperatureMax": 31.77, + "temperatureMin": 23.29, + "daytimeForecast": { + "forecastStart": "2023-09-15T22:00:00Z", + "forecastEnd": "2023-09-16T10:00:00Z", + "cloudCover": 0.18, + "conditionCode": "MostlyClear", + "humidity": 0.65, + "precipitationAmount": 0.0, + "precipitationAmountByType": {}, + "precipitationChance": 0.0, + "precipitationType": "clear", + "snowfallAmount": 0.0, + "windDirection": 68, + "windSpeed": 6.49 + }, + "overnightForecast": { + "forecastStart": "2023-09-16T10:00:00Z", + "forecastEnd": "2023-09-16T22:00:00Z", + "cloudCover": 0.56, + "conditionCode": "PartlyCloudy", + "humidity": 0.87, + "precipitationAmount": 0.0, + "precipitationAmountByType": {}, + "precipitationChance": 0.0, + "precipitationType": "clear", + "snowfallAmount": 0.0, + "windDirection": 158, + "windSpeed": 7.94 + } + }, + { + "forecastStart": "2023-09-16T15:00:00Z", + "forecastEnd": "2023-09-17T15:00:00Z", + "conditionCode": "Thunderstorms", + "maxUvIndex": 8, + "moonPhase": "waxingCrescent", + "moonrise": "2023-09-16T22:23:20Z", + "moonset": "2023-09-17T10:06:21Z", + "precipitationAmount": 5.3, + "precipitationAmountByType": {}, + "precipitationChance": 0.35, + "precipitationType": "rain", + "snowfallAmount": 0.0, + "solarMidnight": "2023-09-17T14:51:45Z", + "solarNoon": "2023-09-17T02:51:35Z", + "sunrise": "2023-09-16T20:40:43Z", + "sunriseCivil": "2023-09-16T20:15:12Z", + "sunriseNautical": "2023-09-16T19:45:21Z", + "sunriseAstronomical": "2023-09-16T19:14:53Z", + "sunset": "2023-09-17T09:02:19Z", + "sunsetCivil": "2023-09-17T09:27:50Z", + "sunsetNautical": "2023-09-17T09:57:36Z", + "sunsetAstronomical": "2023-09-17T10:28:03Z", + "temperatureMax": 30.68, + "temperatureMin": 23.21, + "daytimeForecast": { + "forecastStart": "2023-09-16T22:00:00Z", + "forecastEnd": "2023-09-17T10:00:00Z", + "cloudCover": 0.38, + "conditionCode": "PartlyCloudy", + "humidity": 0.69, + "precipitationAmount": 3.8, + "precipitationAmountByType": {}, + "precipitationChance": 0.22, + "precipitationType": "rain", + "snowfallAmount": 0.0, + "windDirection": 273, + "windSpeed": 8.43 + }, + "overnightForecast": { + "forecastStart": "2023-09-17T10:00:00Z", + "forecastEnd": "2023-09-17T22:00:00Z", + "cloudCover": 0.52, + "conditionCode": "Thunderstorms", + "humidity": 0.9, + "precipitationAmount": 2.0, + "precipitationAmountByType": {}, + "precipitationChance": 0.43, + "precipitationType": "rain", + "snowfallAmount": 0.0, + "windDirection": 228, + "windSpeed": 4.22 + } + }, + { + "forecastStart": "2023-09-17T15:00:00Z", + "forecastEnd": "2023-09-18T15:00:00Z", + "conditionCode": "Thunderstorms", + "maxUvIndex": 6, + "moonPhase": "waxingCrescent", + "moonrise": "2023-09-17T23:22:07Z", + "moonset": "2023-09-18T10:31:34Z", + "precipitationAmount": 2.1, + "precipitationAmountByType": {}, + "precipitationChance": 0.49, + "precipitationType": "rain", + "snowfallAmount": 0.0, + "solarMidnight": "2023-09-18T14:51:24Z", + "solarNoon": "2023-09-18T02:51:14Z", + "sunrise": "2023-09-17T20:41:28Z", + "sunriseCivil": "2023-09-17T20:15:58Z", + "sunriseNautical": "2023-09-17T19:46:09Z", + "sunriseAstronomical": "2023-09-17T19:15:46Z", + "sunset": "2023-09-18T09:00:51Z", + "sunsetCivil": "2023-09-18T09:26:21Z", + "sunsetNautical": "2023-09-18T09:56:06Z", + "sunsetAstronomical": "2023-09-18T10:26:28Z", + "temperatureMax": 28.15, + "temperatureMin": 22.47, + "daytimeForecast": { + "forecastStart": "2023-09-17T22:00:00Z", + "forecastEnd": "2023-09-18T10:00:00Z", + "cloudCover": 0.68, + "conditionCode": "MostlyCloudy", + "humidity": 0.73, + "precipitationAmount": 1.6, + "precipitationAmountByType": {}, + "precipitationChance": 0.3, + "precipitationType": "rain", + "snowfallAmount": 0.0, + "windDirection": 336, + "windSpeed": 12.53 + }, + "overnightForecast": { + "forecastStart": "2023-09-18T10:00:00Z", + "forecastEnd": "2023-09-18T22:00:00Z", + "cloudCover": 0.84, + "conditionCode": "MostlyCloudy", + "humidity": 0.87, + "precipitationAmount": 0.0, + "precipitationAmountByType": {}, + "precipitationChance": 0.26, + "precipitationType": "clear", + "snowfallAmount": 0.0, + "windDirection": 162, + "windSpeed": 8.23 + } + } + ] + }, + "forecastHourly": { + "name": "HourlyForecast", + "metadata": { + "attributionURL": "https://developer.apple.com/weatherkit/data-source-attribution/", + "expireTime": "2023-09-08T23:03:04Z", + "latitude": 35.47, + "longitude": 135.749, + "readTime": "2023-09-08T22:03:04Z", + "reportedTime": "2023-09-08T21:02:40Z", + "units": "m", + "version": 1 + }, + "hours": [ + { + "forecastStart": "2023-09-08T14:00:00Z", + "cloudCover": 0.79, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.94, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1009.24, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 22.55, + "temperatureApparent": 24.61, + "temperatureDewPoint": 21.47, + "uvIndex": 0, + "visibility": 17056.0, + "windDirection": 264, + "windGust": 13.44, + "windSpeed": 6.62 + }, + { + "forecastStart": "2023-09-08T15:00:00Z", + "cloudCover": 0.8, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.94, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1009.24, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 22.38, + "temperatureApparent": 24.42, + "temperatureDewPoint": 21.44, + "uvIndex": 0, + "visibility": 19190.0, + "windDirection": 261, + "windGust": 11.91, + "windSpeed": 6.64 + }, + { + "forecastStart": "2023-09-08T16:00:00Z", + "cloudCover": 0.89, + "conditionCode": "Cloudy", + "daylight": false, + "humidity": 0.95, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1009.12, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 21.96, + "temperatureApparent": 23.84, + "temperatureDewPoint": 21.09, + "uvIndex": 0, + "visibility": 17045.0, + "windDirection": 252, + "windGust": 11.15, + "windSpeed": 6.14 + }, + { + "forecastStart": "2023-09-08T17:00:00Z", + "cloudCover": 0.86, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.95, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1009.03, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 21.73, + "temperatureApparent": 23.54, + "temperatureDewPoint": 20.93, + "uvIndex": 0, + "visibility": 16267.0, + "windDirection": 248, + "windGust": 11.57, + "windSpeed": 5.95 + }, + { + "forecastStart": "2023-09-08T18:00:00Z", + "cloudCover": 0.85, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.95, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1009.05, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 21.57, + "temperatureApparent": 23.32, + "temperatureDewPoint": 20.77, + "uvIndex": 0, + "visibility": 17319.0, + "windDirection": 237, + "windGust": 12.42, + "windSpeed": 5.86 + }, + { + "forecastStart": "2023-09-08T19:00:00Z", + "cloudCover": 0.75, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.96, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1009.03, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 21.33, + "temperatureApparent": 23.01, + "temperatureDewPoint": 20.6, + "uvIndex": 0, + "visibility": 16586.0, + "windDirection": 224, + "windGust": 11.3, + "windSpeed": 5.34 + }, + { + "forecastStart": "2023-09-08T20:00:00Z", + "cloudCover": 0.68, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.96, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1009.31, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 21.18, + "temperatureApparent": 22.8, + "temperatureDewPoint": 20.45, + "uvIndex": 0, + "visibility": 15051.0, + "windDirection": 221, + "windGust": 10.57, + "windSpeed": 5.13 + }, + { + "forecastStart": "2023-09-08T21:00:00Z", + "cloudCover": 0.57, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.95, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1009.55, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 21.41, + "temperatureApparent": 23.07, + "temperatureDewPoint": 20.54, + "uvIndex": 0, + "visibility": 14835.0, + "windDirection": 237, + "windGust": 10.63, + "windSpeed": 5.7 + }, + { + "forecastStart": "2023-09-08T22:00:00Z", + "cloudCover": 0.61, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.91, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1009.79, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 22.84, + "temperatureApparent": 24.85, + "temperatureDewPoint": 21.26, + "uvIndex": 1, + "visibility": 20790.0, + "windDirection": 258, + "windGust": 10.47, + "windSpeed": 5.22 + }, + { + "forecastStart": "2023-09-08T23:00:00Z", + "cloudCover": 0.74, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.85, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1009.95, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.98, + "temperatureApparent": 26.11, + "temperatureDewPoint": 21.34, + "uvIndex": 2, + "visibility": 22144.0, + "windDirection": 282, + "windGust": 12.74, + "windSpeed": 5.71 + }, + { + "forecastStart": "2023-09-09T00:00:00Z", + "cloudCover": 0.84, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.8, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.35, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 25.13, + "temperatureApparent": 27.42, + "temperatureDewPoint": 21.52, + "uvIndex": 3, + "visibility": 23376.0, + "windDirection": 294, + "windGust": 13.87, + "windSpeed": 6.53 + }, + { + "forecastStart": "2023-09-09T01:00:00Z", + "cloudCover": 0.72, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.75, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.48, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 26.52, + "temperatureApparent": 29.04, + "temperatureDewPoint": 21.77, + "uvIndex": 5, + "visibility": 23945.0, + "windDirection": 308, + "windGust": 16.04, + "windSpeed": 6.54 + }, + { + "forecastStart": "2023-09-09T02:00:00Z", + "cloudCover": 0.76, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.72, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.23, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 27.55, + "temperatureApparent": 30.26, + "temperatureDewPoint": 21.96, + "uvIndex": 6, + "visibility": 19031.0, + "windDirection": 314, + "windGust": 18.1, + "windSpeed": 7.32 + }, + { + "forecastStart": "2023-09-09T03:00:00Z", + "cloudCover": 0.7, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.69, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1009.86, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 28.27, + "temperatureApparent": 31.12, + "temperatureDewPoint": 22.09, + "uvIndex": 6, + "visibility": 20583.0, + "windDirection": 317, + "windGust": 20.77, + "windSpeed": 9.1 + }, + { + "forecastStart": "2023-09-09T04:00:00Z", + "cloudCover": 0.69, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.68, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1009.65, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 28.62, + "temperatureApparent": 31.53, + "temperatureDewPoint": 22.13, + "uvIndex": 6, + "visibility": 20816.0, + "windDirection": 311, + "windGust": 21.27, + "windSpeed": 10.21 + }, + { + "forecastStart": "2023-09-09T05:00:00Z", + "cloudCover": 0.71, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.69, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1009.48, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 28.42, + "temperatureApparent": 31.3, + "temperatureDewPoint": 22.14, + "uvIndex": 5, + "visibility": 25254.0, + "windDirection": 317, + "windGust": 19.62, + "windSpeed": 10.53 + }, + { + "forecastStart": "2023-09-09T06:00:00Z", + "cloudCover": 0.86, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.71, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1009.54, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 27.9, + "temperatureApparent": 30.76, + "temperatureDewPoint": 22.2, + "uvIndex": 3, + "visibility": 23283.0, + "windDirection": 335, + "windGust": 18.98, + "windSpeed": 8.63 + }, + { + "forecastStart": "2023-09-09T07:00:00Z", + "cloudCover": 0.84, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.74, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1009.76, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 27.12, + "temperatureApparent": 29.88, + "temperatureDewPoint": 22.17, + "uvIndex": 2, + "visibility": 24299.0, + "windDirection": 338, + "windGust": 17.04, + "windSpeed": 7.75 + }, + { + "forecastStart": "2023-09-09T08:00:00Z", + "cloudCover": 0.72, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.78, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.05, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 26.38, + "temperatureApparent": 29.06, + "temperatureDewPoint": 22.15, + "uvIndex": 0, + "visibility": 21872.0, + "windDirection": 342, + "windGust": 14.75, + "windSpeed": 6.26 + }, + { + "forecastStart": "2023-09-09T09:00:00Z", + "cloudCover": 0.72, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.82, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.38, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 25.37, + "temperatureApparent": 27.88, + "temperatureDewPoint": 21.99, + "uvIndex": 0, + "visibility": 19645.0, + "windDirection": 344, + "windGust": 10.43, + "windSpeed": 5.2 + }, + { + "forecastStart": "2023-09-09T10:00:00Z", + "cloudCover": 0.65, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.85, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.73, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.53, + "temperatureApparent": 26.92, + "temperatureDewPoint": 21.88, + "uvIndex": 0, + "visibility": 20088.0, + "windDirection": 339, + "windGust": 6.95, + "windSpeed": 3.59 + }, + { + "forecastStart": "2023-09-09T11:00:00Z", + "cloudCover": 0.51, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.87, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.3, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.07, + "temperatureApparent": 26.39, + "temperatureDewPoint": 21.81, + "uvIndex": 0, + "visibility": 17853.0, + "windDirection": 326, + "windGust": 5.27, + "windSpeed": 2.1 + }, + { + "forecastStart": "2023-09-09T12:00:00Z", + "cloudCover": 0.53, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.88, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.52, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.87, + "temperatureApparent": 26.15, + "temperatureDewPoint": 21.76, + "uvIndex": 0, + "visibility": 15352.0, + "windDirection": 257, + "windGust": 5.48, + "windSpeed": 0.93 + }, + { + "forecastStart": "2023-09-09T13:00:00Z", + "cloudCover": 0.57, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.9, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.53, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.53, + "temperatureApparent": 25.79, + "temperatureDewPoint": 21.79, + "uvIndex": 0, + "visibility": 16260.0, + "windDirection": 188, + "windGust": 4.44, + "windSpeed": 1.79 + }, + { + "forecastStart": "2023-09-09T14:00:00Z", + "cloudCover": 0.64, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.92, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.46, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.11, + "temperatureApparent": 25.29, + "temperatureDewPoint": 21.67, + "uvIndex": 0, + "visibility": 17443.0, + "windDirection": 183, + "windGust": 4.49, + "windSpeed": 2.19 + }, + { + "forecastStart": "2023-09-09T15:00:00Z", + "cloudCover": 0.45, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.93, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.21, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 22.59, + "temperatureApparent": 24.62, + "temperatureDewPoint": 21.36, + "uvIndex": 0, + "visibility": 17538.0, + "windDirection": 179, + "windGust": 5.32, + "windSpeed": 2.65 + }, + { + "forecastStart": "2023-09-09T16:00:00Z", + "cloudCover": 0.42, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.94, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.09, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 22.09, + "temperatureApparent": 23.98, + "temperatureDewPoint": 21.08, + "uvIndex": 0, + "visibility": 18544.0, + "windDirection": 173, + "windGust": 5.81, + "windSpeed": 3.2 + }, + { + "forecastStart": "2023-09-09T17:00:00Z", + "cloudCover": 0.54, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.94, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.88, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 21.85, + "temperatureApparent": 23.66, + "temperatureDewPoint": 20.91, + "uvIndex": 0, + "visibility": 15814.0, + "windDirection": 159, + "windGust": 5.53, + "windSpeed": 3.16 + }, + { + "forecastStart": "2023-09-09T18:00:00Z", + "cloudCover": 0.54, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.94, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.94, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 21.62, + "temperatureApparent": 23.34, + "temperatureDewPoint": 20.68, + "uvIndex": 0, + "visibility": 13955.0, + "windDirection": 153, + "windGust": 6.09, + "windSpeed": 3.36 + }, + { + "forecastStart": "2023-09-09T19:00:00Z", + "cloudCover": 0.51, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.94, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.96, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 21.42, + "temperatureApparent": 23.06, + "temperatureDewPoint": 20.48, + "uvIndex": 0, + "visibility": 13042.0, + "windDirection": 150, + "windGust": 6.83, + "windSpeed": 3.71 + }, + { + "forecastStart": "2023-09-09T20:00:00Z", + "cloudCover": 0.7, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.94, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.29, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 21.04, + "temperatureApparent": 22.52, + "temperatureDewPoint": 20.04, + "uvIndex": 0, + "visibility": 13016.0, + "windDirection": 156, + "windGust": 7.98, + "windSpeed": 4.27 + }, + { + "forecastStart": "2023-09-09T21:00:00Z", + "cloudCover": 0.76, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.94, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.61, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 21.25, + "temperatureApparent": 22.78, + "temperatureDewPoint": 20.18, + "uvIndex": 0, + "visibility": 13648.0, + "windDirection": 156, + "windGust": 8.4, + "windSpeed": 4.69 + }, + { + "forecastStart": "2023-09-09T22:00:00Z", + "cloudCover": 0.68, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.9, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.87, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.06, + "temperatureApparent": 25.08, + "temperatureDewPoint": 21.26, + "uvIndex": 1, + "visibility": 20589.0, + "windDirection": 150, + "windGust": 7.66, + "windSpeed": 4.33 + }, + { + "forecastStart": "2023-09-09T23:00:00Z", + "cloudCover": 0.58, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.82, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.93, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 25.64, + "temperatureApparent": 28.29, + "temperatureDewPoint": 22.26, + "uvIndex": 2, + "visibility": 24505.0, + "windDirection": 123, + "windGust": 9.63, + "windSpeed": 3.91 + }, + { + "forecastStart": "2023-09-10T00:00:00Z", + "cloudCover": 0.63, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.75, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.93, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 27.42, + "temperatureApparent": 30.44, + "temperatureDewPoint": 22.64, + "uvIndex": 4, + "visibility": 25988.0, + "windDirection": 105, + "windGust": 12.59, + "windSpeed": 3.96 + }, + { + "forecastStart": "2023-09-10T01:00:00Z", + "cloudCover": 0.66, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.7, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.79, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 28.88, + "temperatureApparent": 32.23, + "temperatureDewPoint": 22.95, + "uvIndex": 5, + "visibility": 26343.0, + "windDirection": 99, + "windGust": 14.17, + "windSpeed": 4.06 + }, + { + "forecastStart": "2023-09-10T02:00:00Z", + "cloudCover": 0.62, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.66, + "precipitationAmount": 0.3, + "precipitationIntensity": 0.3, + "precipitationChance": 0.07, + "precipitationType": "rain", + "pressure": 1011.29, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 29.89, + "temperatureApparent": 33.37, + "temperatureDewPoint": 22.95, + "uvIndex": 6, + "visibility": 20305.0, + "windDirection": 93, + "windGust": 17.75, + "windSpeed": 4.87 + }, + { + "forecastStart": "2023-09-10T03:00:00Z", + "cloudCover": 0.74, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.64, + "precipitationAmount": 0.3, + "precipitationIntensity": 0.3, + "precipitationChance": 0.11, + "precipitationType": "rain", + "pressure": 1010.78, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 30.63, + "temperatureApparent": 34.32, + "temperatureDewPoint": 23.15, + "uvIndex": 6, + "visibility": 21524.0, + "windDirection": 78, + "windGust": 17.43, + "windSpeed": 4.54 + }, + { + "forecastStart": "2023-09-10T04:00:00Z", + "cloudCover": 0.74, + "conditionCode": "Drizzle", + "daylight": true, + "humidity": 0.66, + "precipitationAmount": 0.4, + "precipitationIntensity": 0.4, + "precipitationChance": 0.15, + "precipitationType": "rain", + "pressure": 1010.37, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 30.32, + "temperatureApparent": 33.97, + "temperatureDewPoint": 23.16, + "uvIndex": 5, + "visibility": 19608.0, + "windDirection": 60, + "windGust": 15.24, + "windSpeed": 4.9 + }, + { + "forecastStart": "2023-09-10T05:00:00Z", + "cloudCover": 0.79, + "conditionCode": "Drizzle", + "daylight": true, + "humidity": 0.67, + "precipitationAmount": 0.7, + "precipitationIntensity": 0.7, + "precipitationChance": 0.17, + "precipitationType": "rain", + "pressure": 1010.09, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 30.01, + "temperatureApparent": 33.68, + "temperatureDewPoint": 23.26, + "uvIndex": 4, + "visibility": 19170.0, + "windDirection": 80, + "windGust": 13.53, + "windSpeed": 5.98 + }, + { + "forecastStart": "2023-09-10T06:00:00Z", + "cloudCover": 0.8, + "conditionCode": "Drizzle", + "daylight": true, + "humidity": 0.7, + "precipitationAmount": 1.0, + "precipitationIntensity": 1.0, + "precipitationChance": 0.17, + "precipitationType": "rain", + "pressure": 1010.0, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 29.51, + "temperatureApparent": 33.17, + "temperatureDewPoint": 23.37, + "uvIndex": 3, + "visibility": 20385.0, + "windDirection": 83, + "windGust": 12.55, + "windSpeed": 6.84 + }, + { + "forecastStart": "2023-09-10T07:00:00Z", + "cloudCover": 0.88, + "conditionCode": "Drizzle", + "daylight": true, + "humidity": 0.73, + "precipitationAmount": 0.4, + "precipitationIntensity": 0.4, + "precipitationChance": 0.16, + "precipitationType": "rain", + "pressure": 1010.27, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 28.73, + "temperatureApparent": 32.28, + "temperatureDewPoint": 23.36, + "uvIndex": 2, + "visibility": 21033.0, + "windDirection": 90, + "windGust": 10.16, + "windSpeed": 6.07 + }, + { + "forecastStart": "2023-09-10T08:00:00Z", + "cloudCover": 0.92, + "conditionCode": "Cloudy", + "daylight": true, + "humidity": 0.77, + "precipitationAmount": 0.5, + "precipitationIntensity": 0.5, + "precipitationChance": 0.14, + "precipitationType": "rain", + "pressure": 1010.71, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 27.6, + "temperatureApparent": 30.9, + "temperatureDewPoint": 23.16, + "uvIndex": 0, + "visibility": 19490.0, + "windDirection": 101, + "windGust": 8.18, + "windSpeed": 4.82 + }, + { + "forecastStart": "2023-09-10T09:00:00Z", + "cloudCover": 0.93, + "conditionCode": "Cloudy", + "daylight": true, + "humidity": 0.82, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.9, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 26.52, + "temperatureApparent": 29.7, + "temperatureDewPoint": 23.2, + "uvIndex": 0, + "visibility": 15809.0, + "windDirection": 128, + "windGust": 8.89, + "windSpeed": 4.95 + }, + { + "forecastStart": "2023-09-10T10:00:00Z", + "cloudCover": 0.88, + "conditionCode": "Cloudy", + "daylight": false, + "humidity": 0.86, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.12, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 25.61, + "temperatureApparent": 28.6, + "temperatureDewPoint": 23.02, + "uvIndex": 0, + "visibility": 16975.0, + "windDirection": 134, + "windGust": 10.03, + "windSpeed": 4.52 + }, + { + "forecastStart": "2023-09-10T11:00:00Z", + "cloudCover": 0.87, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.87, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.43, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 25.06, + "temperatureApparent": 27.88, + "temperatureDewPoint": 22.78, + "uvIndex": 0, + "visibility": 17463.0, + "windDirection": 137, + "windGust": 12.4, + "windSpeed": 5.41 + }, + { + "forecastStart": "2023-09-10T12:00:00Z", + "cloudCover": 0.82, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.87, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.58, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.78, + "temperatureApparent": 27.45, + "temperatureDewPoint": 22.51, + "uvIndex": 0, + "visibility": 18599.0, + "windDirection": 143, + "windGust": 16.36, + "windSpeed": 6.31 + }, + { + "forecastStart": "2023-09-10T13:00:00Z", + "cloudCover": 0.82, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.88, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.55, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.52, + "temperatureApparent": 27.12, + "temperatureDewPoint": 22.4, + "uvIndex": 0, + "visibility": 19560.0, + "windDirection": 144, + "windGust": 19.66, + "windSpeed": 7.23 + }, + { + "forecastStart": "2023-09-10T14:00:00Z", + "cloudCover": 0.72, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.88, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.4, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.29, + "temperatureApparent": 26.81, + "temperatureDewPoint": 22.25, + "uvIndex": 0, + "visibility": 20164.0, + "windDirection": 141, + "windGust": 21.15, + "windSpeed": 7.46 + }, + { + "forecastStart": "2023-09-10T15:00:00Z", + "cloudCover": 0.74, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.89, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.23, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.95, + "temperatureApparent": 26.33, + "temperatureDewPoint": 21.99, + "uvIndex": 0, + "visibility": 20723.0, + "windDirection": 141, + "windGust": 22.26, + "windSpeed": 7.84 + }, + { + "forecastStart": "2023-09-10T16:00:00Z", + "cloudCover": 0.7, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.89, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.01, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.77, + "temperatureApparent": 26.06, + "temperatureDewPoint": 21.81, + "uvIndex": 0, + "visibility": 20584.0, + "windDirection": 144, + "windGust": 23.53, + "windSpeed": 8.63 + }, + { + "forecastStart": "2023-09-10T17:00:00Z", + "cloudCover": 0.61, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.89, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.78, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.47, + "temperatureApparent": 25.65, + "temperatureDewPoint": 21.59, + "uvIndex": 0, + "visibility": 21559.0, + "windDirection": 144, + "windGust": 22.83, + "windSpeed": 8.61 + }, + { + "forecastStart": "2023-09-10T18:00:00Z", + "cloudCover": 0.74, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.9, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.69, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.28, + "temperatureApparent": 25.4, + "temperatureDewPoint": 21.47, + "uvIndex": 0, + "visibility": 20210.0, + "windDirection": 143, + "windGust": 23.7, + "windSpeed": 8.7 + }, + { + "forecastStart": "2023-09-10T19:00:00Z", + "cloudCover": 0.84, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.9, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.77, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.14, + "temperatureApparent": 25.23, + "temperatureDewPoint": 21.41, + "uvIndex": 0, + "visibility": 20532.0, + "windDirection": 140, + "windGust": 24.24, + "windSpeed": 8.74 + }, + { + "forecastStart": "2023-09-10T20:00:00Z", + "cloudCover": 0.89, + "conditionCode": "Cloudy", + "daylight": false, + "humidity": 0.9, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.89, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.33, + "temperatureApparent": 25.5, + "temperatureDewPoint": 21.6, + "uvIndex": 0, + "visibility": 21210.0, + "windDirection": 138, + "windGust": 23.99, + "windSpeed": 8.81 + }, + { + "forecastStart": "2023-09-10T21:00:00Z", + "cloudCover": 0.73, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.88, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.1, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.67, + "temperatureApparent": 25.86, + "temperatureDewPoint": 21.56, + "uvIndex": 0, + "visibility": 22103.0, + "windDirection": 138, + "windGust": 25.55, + "windSpeed": 9.05 + }, + { + "forecastStart": "2023-09-10T22:00:00Z", + "cloudCover": 0.71, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.84, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.29, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.61, + "temperatureApparent": 26.97, + "temperatureDewPoint": 21.8, + "uvIndex": 1, + "visibility": 22607.0, + "windDirection": 140, + "windGust": 29.08, + "windSpeed": 10.37 + }, + { + "forecastStart": "2023-09-10T23:00:00Z", + "cloudCover": 0.7, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.79, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.36, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 25.85, + "temperatureApparent": 28.36, + "temperatureDewPoint": 21.89, + "uvIndex": 2, + "visibility": 23231.0, + "windDirection": 140, + "windGust": 34.13, + "windSpeed": 12.56 + }, + { + "forecastStart": "2023-09-11T00:00:00Z", + "cloudCover": 0.68, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.74, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.39, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 27.25, + "temperatureApparent": 30.09, + "temperatureDewPoint": 22.3, + "uvIndex": 3, + "visibility": 24284.0, + "windDirection": 140, + "windGust": 38.2, + "windSpeed": 15.65 + }, + { + "forecastStart": "2023-09-11T01:00:00Z", + "cloudCover": 0.58, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.7, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.31, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 28.39, + "temperatureApparent": 31.35, + "temperatureDewPoint": 22.3, + "uvIndex": 5, + "visibility": 24490.0, + "windDirection": 141, + "windGust": 37.55, + "windSpeed": 15.78 + }, + { + "forecastStart": "2023-09-11T02:00:00Z", + "cloudCover": 0.63, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.66, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.98, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 29.55, + "temperatureApparent": 32.71, + "temperatureDewPoint": 22.43, + "uvIndex": 6, + "visibility": 23811.0, + "windDirection": 143, + "windGust": 35.86, + "windSpeed": 15.41 + }, + { + "forecastStart": "2023-09-11T03:00:00Z", + "cloudCover": 0.64, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.63, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.61, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 30.27, + "temperatureApparent": 33.55, + "temperatureDewPoint": 22.5, + "uvIndex": 6, + "visibility": 20414.0, + "windDirection": 141, + "windGust": 35.88, + "windSpeed": 15.51 + }, + { + "forecastStart": "2023-09-11T04:00:00Z", + "cloudCover": 0.74, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.63, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.36, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 30.43, + "temperatureApparent": 33.81, + "temperatureDewPoint": 22.65, + "uvIndex": 5, + "visibility": 19760.0, + "windDirection": 140, + "windGust": 35.99, + "windSpeed": 15.75 + }, + { + "forecastStart": "2023-09-11T05:00:00Z", + "cloudCover": 0.76, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.64, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.11, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 30.15, + "temperatureApparent": 33.47, + "temperatureDewPoint": 22.59, + "uvIndex": 4, + "visibility": 24662.0, + "windDirection": 137, + "windGust": 33.61, + "windSpeed": 15.36 + }, + { + "forecastStart": "2023-09-11T06:00:00Z", + "cloudCover": 0.77, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.64, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1009.98, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 29.97, + "temperatureApparent": 33.23, + "temperatureDewPoint": 22.52, + "uvIndex": 3, + "visibility": 26577.0, + "windDirection": 138, + "windGust": 32.61, + "windSpeed": 14.98 + }, + { + "forecastStart": "2023-09-11T07:00:00Z", + "cloudCover": 0.64, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.66, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.13, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 29.25, + "temperatureApparent": 32.28, + "temperatureDewPoint": 22.24, + "uvIndex": 2, + "visibility": 24239.0, + "windDirection": 138, + "windGust": 28.1, + "windSpeed": 13.88 + }, + { + "forecastStart": "2023-09-11T08:00:00Z", + "cloudCover": 0.56, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.69, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.48, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 28.32, + "temperatureApparent": 31.19, + "temperatureDewPoint": 22.14, + "uvIndex": 0, + "visibility": 25056.0, + "windDirection": 137, + "windGust": 24.22, + "windSpeed": 13.02 + }, + { + "forecastStart": "2023-09-11T09:00:00Z", + "cloudCover": 0.55, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.73, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.81, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 27.15, + "temperatureApparent": 29.77, + "temperatureDewPoint": 21.85, + "uvIndex": 0, + "visibility": 23658.0, + "windDirection": 138, + "windGust": 22.5, + "windSpeed": 11.94 + }, + { + "forecastStart": "2023-09-11T10:00:00Z", + "cloudCover": 0.63, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.76, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.29, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 26.29, + "temperatureApparent": 28.77, + "temperatureDewPoint": 21.72, + "uvIndex": 0, + "visibility": 23317.0, + "windDirection": 137, + "windGust": 21.47, + "windSpeed": 11.25 + }, + { + "forecastStart": "2023-09-11T11:00:00Z", + "cloudCover": 0.86, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.8, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.77, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 25.62, + "temperatureApparent": 28.09, + "temperatureDewPoint": 21.83, + "uvIndex": 0, + "visibility": 21978.0, + "windDirection": 141, + "windGust": 22.71, + "windSpeed": 12.39 + }, + { + "forecastStart": "2023-09-11T12:00:00Z", + "cloudCover": 0.86, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.82, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.97, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 25.16, + "temperatureApparent": 27.57, + "temperatureDewPoint": 21.79, + "uvIndex": 0, + "visibility": 20260.0, + "windDirection": 143, + "windGust": 23.67, + "windSpeed": 12.83 + }, + { + "forecastStart": "2023-09-11T13:00:00Z", + "cloudCover": 0.89, + "conditionCode": "Cloudy", + "daylight": false, + "humidity": 0.83, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.97, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.74, + "temperatureApparent": 27.07, + "temperatureDewPoint": 21.7, + "uvIndex": 0, + "visibility": 18240.0, + "windDirection": 146, + "windGust": 23.34, + "windSpeed": 12.62 + }, + { + "forecastStart": "2023-09-11T14:00:00Z", + "cloudCover": 0.88, + "conditionCode": "Cloudy", + "daylight": false, + "humidity": 0.85, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.83, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.41, + "temperatureApparent": 26.71, + "temperatureDewPoint": 21.68, + "uvIndex": 0, + "visibility": 18444.0, + "windDirection": 147, + "windGust": 22.9, + "windSpeed": 12.07 + }, + { + "forecastStart": "2023-09-11T15:00:00Z", + "cloudCover": 0.9, + "conditionCode": "Cloudy", + "daylight": false, + "humidity": 0.86, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.74, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.06, + "temperatureApparent": 26.31, + "temperatureDewPoint": 21.65, + "uvIndex": 0, + "visibility": 20008.0, + "windDirection": 147, + "windGust": 22.01, + "windSpeed": 11.19 + }, + { + "forecastStart": "2023-09-11T16:00:00Z", + "cloudCover": 0.88, + "conditionCode": "Cloudy", + "daylight": false, + "humidity": 0.88, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.56, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.73, + "temperatureApparent": 25.92, + "temperatureDewPoint": 21.55, + "uvIndex": 0, + "visibility": 19191.0, + "windDirection": 149, + "windGust": 21.29, + "windSpeed": 10.97 + }, + { + "forecastStart": "2023-09-11T17:00:00Z", + "cloudCover": 0.85, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.88, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.35, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.64, + "temperatureApparent": 25.79, + "temperatureDewPoint": 21.46, + "uvIndex": 0, + "visibility": 19549.0, + "windDirection": 150, + "windGust": 20.52, + "windSpeed": 10.5 + }, + { + "forecastStart": "2023-09-11T18:00:00Z", + "cloudCover": 0.82, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.88, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.3, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.54, + "temperatureApparent": 25.67, + "temperatureDewPoint": 21.44, + "uvIndex": 0, + "visibility": 19709.0, + "windDirection": 149, + "windGust": 20.04, + "windSpeed": 10.51 + }, + { + "forecastStart": "2023-09-11T19:00:00Z", + "cloudCover": 0.78, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.88, + "precipitationAmount": 0.3, + "precipitationIntensity": 0.3, + "precipitationChance": 0.12, + "precipitationType": "rain", + "pressure": 1011.37, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.35, + "temperatureApparent": 25.42, + "temperatureDewPoint": 21.32, + "uvIndex": 0, + "visibility": 17439.0, + "windDirection": 146, + "windGust": 18.07, + "windSpeed": 10.13 + }, + { + "forecastStart": "2023-09-11T20:00:00Z", + "cloudCover": 0.78, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.89, + "precipitationAmount": 0.2, + "precipitationIntensity": 0.2, + "precipitationChance": 0.13, + "precipitationType": "rain", + "pressure": 1011.53, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.15, + "temperatureApparent": 25.16, + "temperatureDewPoint": 21.2, + "uvIndex": 0, + "visibility": 15297.0, + "windDirection": 141, + "windGust": 16.86, + "windSpeed": 10.34 + }, + { + "forecastStart": "2023-09-11T21:00:00Z", + "cloudCover": 0.78, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.88, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.71, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.43, + "temperatureApparent": 25.54, + "temperatureDewPoint": 21.4, + "uvIndex": 0, + "visibility": 17935.0, + "windDirection": 138, + "windGust": 16.66, + "windSpeed": 10.68 + }, + { + "forecastStart": "2023-09-11T22:00:00Z", + "cloudCover": 0.78, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.86, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.94, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.45, + "temperatureApparent": 26.83, + "temperatureDewPoint": 21.88, + "uvIndex": 1, + "visibility": 17153.0, + "windDirection": 137, + "windGust": 17.21, + "windSpeed": 10.61 + }, + { + "forecastStart": "2023-09-11T23:00:00Z", + "cloudCover": 0.78, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.82, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.05, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 25.55, + "temperatureApparent": 28.22, + "temperatureDewPoint": 22.33, + "uvIndex": 2, + "visibility": 19126.0, + "windDirection": 138, + "windGust": 19.23, + "windSpeed": 11.13 + }, + { + "forecastStart": "2023-09-12T00:00:00Z", + "cloudCover": 0.79, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.79, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.07, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 26.61, + "temperatureApparent": 29.53, + "temperatureDewPoint": 22.63, + "uvIndex": 3, + "visibility": 16639.0, + "windDirection": 140, + "windGust": 20.61, + "windSpeed": 11.13 + }, + { + "forecastStart": "2023-09-12T01:00:00Z", + "cloudCover": 0.82, + "conditionCode": "Drizzle", + "daylight": true, + "humidity": 0.75, + "precipitationAmount": 0.2, + "precipitationIntensity": 0.2, + "precipitationChance": 0.16, + "precipitationType": "rain", + "pressure": 1011.89, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 27.92, + "temperatureApparent": 31.24, + "temperatureDewPoint": 23.12, + "uvIndex": 4, + "visibility": 16716.0, + "windDirection": 141, + "windGust": 23.35, + "windSpeed": 11.98 + }, + { + "forecastStart": "2023-09-12T02:00:00Z", + "cloudCover": 0.85, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.72, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.53, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 28.97, + "temperatureApparent": 32.63, + "temperatureDewPoint": 23.5, + "uvIndex": 5, + "visibility": 19639.0, + "windDirection": 143, + "windGust": 26.45, + "windSpeed": 13.01 + }, + { + "forecastStart": "2023-09-12T03:00:00Z", + "cloudCover": 0.84, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.69, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.15, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 29.76, + "temperatureApparent": 33.53, + "temperatureDewPoint": 23.51, + "uvIndex": 5, + "visibility": 23538.0, + "windDirection": 141, + "windGust": 28.95, + "windSpeed": 13.9 + }, + { + "forecastStart": "2023-09-12T04:00:00Z", + "cloudCover": 0.73, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.67, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.79, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 30.21, + "temperatureApparent": 34.01, + "temperatureDewPoint": 23.45, + "uvIndex": 5, + "visibility": 24964.0, + "windDirection": 141, + "windGust": 27.9, + "windSpeed": 13.95 + }, + { + "forecastStart": "2023-09-12T05:00:00Z", + "cloudCover": 0.64, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.65, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.43, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 30.42, + "temperatureApparent": 34.02, + "temperatureDewPoint": 23.05, + "uvIndex": 4, + "visibility": 26399.0, + "windDirection": 140, + "windGust": 26.53, + "windSpeed": 13.78 + }, + { + "forecastStart": "2023-09-12T06:00:00Z", + "cloudCover": 0.56, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.64, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.21, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 30.07, + "temperatureApparent": 33.39, + "temperatureDewPoint": 22.62, + "uvIndex": 3, + "visibility": 27308.0, + "windDirection": 138, + "windGust": 24.56, + "windSpeed": 13.74 + }, + { + "forecastStart": "2023-09-12T07:00:00Z", + "cloudCover": 0.53, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.66, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.26, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 29.06, + "temperatureApparent": 31.98, + "temperatureDewPoint": 22.06, + "uvIndex": 2, + "visibility": 27514.0, + "windDirection": 138, + "windGust": 22.78, + "windSpeed": 13.21 + }, + { + "forecastStart": "2023-09-12T08:00:00Z", + "cloudCover": 0.48, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.69, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.51, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 28.14, + "temperatureApparent": 30.87, + "temperatureDewPoint": 21.87, + "uvIndex": 0, + "visibility": 27191.0, + "windDirection": 140, + "windGust": 19.92, + "windSpeed": 12.0 + }, + { + "forecastStart": "2023-09-12T09:00:00Z", + "cloudCover": 0.5, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.72, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.8, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 27.18, + "temperatureApparent": 29.73, + "temperatureDewPoint": 21.69, + "uvIndex": 0, + "visibility": 26334.0, + "windDirection": 141, + "windGust": 17.65, + "windSpeed": 10.97 + }, + { + "forecastStart": "2023-09-12T10:00:00Z", + "cloudCover": 0.54, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.75, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.23, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 26.19, + "temperatureApparent": 28.55, + "temperatureDewPoint": 21.45, + "uvIndex": 0, + "visibility": 24588.0, + "windDirection": 143, + "windGust": 15.87, + "windSpeed": 10.23 + }, + { + "forecastStart": "2023-09-12T11:00:00Z", + "cloudCover": 0.57, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.78, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.4, + "precipitationType": "clear", + "pressure": 1011.79, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 25.36, + "temperatureApparent": 27.6, + "temperatureDewPoint": 21.33, + "uvIndex": 0, + "visibility": 22303.0, + "windDirection": 146, + "windGust": 13.9, + "windSpeed": 9.39 + }, + { + "forecastStart": "2023-09-12T12:00:00Z", + "cloudCover": 0.6, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.81, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.47, + "precipitationType": "clear", + "pressure": 1012.12, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.68, + "temperatureApparent": 26.82, + "temperatureDewPoint": 21.24, + "uvIndex": 0, + "visibility": 20535.0, + "windDirection": 147, + "windGust": 13.32, + "windSpeed": 8.9 + }, + { + "forecastStart": "2023-09-12T13:00:00Z", + "cloudCover": 0.66, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.83, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.4, + "precipitationType": "clear", + "pressure": 1012.18, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.23, + "temperatureApparent": 26.32, + "temperatureDewPoint": 21.2, + "uvIndex": 0, + "visibility": 19800.0, + "windDirection": 149, + "windGust": 13.18, + "windSpeed": 8.59 + }, + { + "forecastStart": "2023-09-12T14:00:00Z", + "cloudCover": 0.71, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.85, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.09, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.91, + "temperatureApparent": 26.0, + "temperatureDewPoint": 21.27, + "uvIndex": 0, + "visibility": 19587.0, + "windDirection": 149, + "windGust": 13.84, + "windSpeed": 8.87 + }, + { + "forecastStart": "2023-09-12T15:00:00Z", + "cloudCover": 0.76, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.87, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.99, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.61, + "temperatureApparent": 25.68, + "temperatureDewPoint": 21.28, + "uvIndex": 0, + "visibility": 19418.0, + "windDirection": 149, + "windGust": 15.08, + "windSpeed": 8.93 + }, + { + "forecastStart": "2023-09-12T16:00:00Z", + "cloudCover": 0.73, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.88, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.93, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.18, + "temperatureApparent": 25.12, + "temperatureDewPoint": 21.01, + "uvIndex": 0, + "visibility": 19187.0, + "windDirection": 146, + "windGust": 16.74, + "windSpeed": 9.49 + }, + { + "forecastStart": "2023-09-12T17:00:00Z", + "cloudCover": 0.74, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.88, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.75, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 22.86, + "temperatureApparent": 24.72, + "temperatureDewPoint": 20.84, + "uvIndex": 0, + "visibility": 19001.0, + "windDirection": 146, + "windGust": 17.45, + "windSpeed": 9.12 + }, + { + "forecastStart": "2023-09-12T18:00:00Z", + "cloudCover": 0.73, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.89, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.77, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 22.62, + "temperatureApparent": 24.41, + "temperatureDewPoint": 20.68, + "uvIndex": 0, + "visibility": 18698.0, + "windDirection": 149, + "windGust": 17.04, + "windSpeed": 8.68 + }, + { + "forecastStart": "2023-09-12T19:00:00Z", + "cloudCover": 0.73, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.9, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.93, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 22.37, + "temperatureApparent": 24.1, + "temperatureDewPoint": 20.58, + "uvIndex": 0, + "visibility": 17831.0, + "windDirection": 149, + "windGust": 16.8, + "windSpeed": 8.61 + }, + { + "forecastStart": "2023-09-12T20:00:00Z", + "cloudCover": 0.74, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.9, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.23, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 22.15, + "temperatureApparent": 23.85, + "temperatureDewPoint": 20.5, + "uvIndex": 0, + "visibility": 16846.0, + "windDirection": 150, + "windGust": 15.35, + "windSpeed": 8.36 + }, + { + "forecastStart": "2023-09-12T21:00:00Z", + "cloudCover": 0.75, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.89, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.49, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 22.59, + "temperatureApparent": 24.36, + "temperatureDewPoint": 20.65, + "uvIndex": 0, + "visibility": 16919.0, + "windDirection": 155, + "windGust": 14.09, + "windSpeed": 7.77 + }, + { + "forecastStart": "2023-09-12T22:00:00Z", + "cloudCover": 0.71, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.84, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.72, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.82, + "temperatureApparent": 25.82, + "temperatureDewPoint": 21.03, + "uvIndex": 1, + "visibility": 19326.0, + "windDirection": 152, + "windGust": 14.04, + "windSpeed": 7.25 + }, + { + "forecastStart": "2023-09-12T23:00:00Z", + "cloudCover": 0.65, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.78, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.85, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 25.5, + "temperatureApparent": 27.77, + "temperatureDewPoint": 21.38, + "uvIndex": 2, + "visibility": 22800.0, + "windDirection": 149, + "windGust": 15.31, + "windSpeed": 7.14 + }, + { + "forecastStart": "2023-09-13T00:00:00Z", + "cloudCover": 0.6, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.73, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.89, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 27.13, + "temperatureApparent": 29.74, + "temperatureDewPoint": 21.83, + "uvIndex": 4, + "visibility": 24706.0, + "windDirection": 141, + "windGust": 16.42, + "windSpeed": 6.89 + }, + { + "forecastStart": "2023-09-13T01:00:00Z", + "cloudCover": 0.64, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.68, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.65, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 28.44, + "temperatureApparent": 31.24, + "temperatureDewPoint": 21.96, + "uvIndex": 5, + "visibility": 23309.0, + "windDirection": 137, + "windGust": 18.64, + "windSpeed": 6.65 + }, + { + "forecastStart": "2023-09-13T02:00:00Z", + "cloudCover": 0.73, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.64, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.26, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 29.41, + "temperatureApparent": 32.28, + "temperatureDewPoint": 21.89, + "uvIndex": 5, + "visibility": 20329.0, + "windDirection": 128, + "windGust": 21.69, + "windSpeed": 7.12 + }, + { + "forecastStart": "2023-09-13T03:00:00Z", + "cloudCover": 0.76, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.62, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.88, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 30.06, + "temperatureApparent": 33.0, + "temperatureDewPoint": 21.88, + "uvIndex": 6, + "visibility": 17382.0, + "windDirection": 111, + "windGust": 23.41, + "windSpeed": 7.33 + }, + { + "forecastStart": "2023-09-13T04:00:00Z", + "cloudCover": 0.72, + "conditionCode": "Drizzle", + "daylight": true, + "humidity": 0.61, + "precipitationAmount": 0.9, + "precipitationIntensity": 0.9, + "precipitationChance": 0.12, + "precipitationType": "rain", + "pressure": 1011.55, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 30.4, + "temperatureApparent": 33.43, + "temperatureDewPoint": 21.98, + "uvIndex": 5, + "visibility": 18579.0, + "windDirection": 56, + "windGust": 23.1, + "windSpeed": 8.09 + }, + { + "forecastStart": "2023-09-13T05:00:00Z", + "cloudCover": 0.72, + "conditionCode": "Drizzle", + "daylight": true, + "humidity": 0.61, + "precipitationAmount": 1.9, + "precipitationIntensity": 1.9, + "precipitationChance": 0.12, + "precipitationType": "rain", + "pressure": 1011.29, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 30.2, + "temperatureApparent": 33.16, + "temperatureDewPoint": 21.9, + "uvIndex": 4, + "visibility": 18850.0, + "windDirection": 20, + "windGust": 21.81, + "windSpeed": 9.46 + }, + { + "forecastStart": "2023-09-13T06:00:00Z", + "cloudCover": 0.74, + "conditionCode": "Drizzle", + "daylight": true, + "humidity": 0.63, + "precipitationAmount": 2.3, + "precipitationIntensity": 2.3, + "precipitationChance": 0.11, + "precipitationType": "rain", + "pressure": 1011.17, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 29.67, + "temperatureApparent": 32.59, + "temperatureDewPoint": 21.93, + "uvIndex": 3, + "visibility": 20634.0, + "windDirection": 20, + "windGust": 19.72, + "windSpeed": 9.8 + }, + { + "forecastStart": "2023-09-13T07:00:00Z", + "cloudCover": 0.69, + "conditionCode": "Drizzle", + "daylight": true, + "humidity": 0.68, + "precipitationAmount": 1.8, + "precipitationIntensity": 1.8, + "precipitationChance": 0.1, + "precipitationType": "rain", + "pressure": 1011.32, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 28.77, + "temperatureApparent": 31.81, + "temperatureDewPoint": 22.37, + "uvIndex": 1, + "visibility": 19468.0, + "windDirection": 18, + "windGust": 17.55, + "windSpeed": 9.23 + }, + { + "forecastStart": "2023-09-13T08:00:00Z", + "cloudCover": 0.73, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.76, + "precipitationAmount": 0.8, + "precipitationIntensity": 0.8, + "precipitationChance": 0.1, + "precipitationType": "rain", + "pressure": 1011.6, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 27.61, + "temperatureApparent": 30.78, + "temperatureDewPoint": 22.91, + "uvIndex": 0, + "visibility": 18451.0, + "windDirection": 27, + "windGust": 15.08, + "windSpeed": 8.05 + }, + { + "forecastStart": "2023-09-13T09:00:00Z", + "cloudCover": 0.76, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.82, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.94, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 26.33, + "temperatureApparent": 29.4, + "temperatureDewPoint": 23.01, + "uvIndex": 0, + "visibility": 19184.0, + "windDirection": 32, + "windGust": 12.17, + "windSpeed": 6.68 + }, + { + "forecastStart": "2023-09-13T10:00:00Z", + "cloudCover": 0.84, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.85, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.3, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 25.54, + "temperatureApparent": 28.46, + "temperatureDewPoint": 22.87, + "uvIndex": 0, + "visibility": 17878.0, + "windDirection": 69, + "windGust": 11.64, + "windSpeed": 6.69 + }, + { + "forecastStart": "2023-09-13T11:00:00Z", + "cloudCover": 0.84, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.87, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.71, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.98, + "temperatureApparent": 27.73, + "temperatureDewPoint": 22.63, + "uvIndex": 0, + "visibility": 19357.0, + "windDirection": 155, + "windGust": 11.91, + "windSpeed": 6.23 + }, + { + "forecastStart": "2023-09-13T12:00:00Z", + "cloudCover": 0.82, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.88, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.96, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.53, + "temperatureApparent": 27.11, + "temperatureDewPoint": 22.34, + "uvIndex": 0, + "visibility": 19658.0, + "windDirection": 161, + "windGust": 12.47, + "windSpeed": 5.73 + }, + { + "forecastStart": "2023-09-13T13:00:00Z", + "cloudCover": 0.82, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.89, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1013.03, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.17, + "temperatureApparent": 26.69, + "temperatureDewPoint": 22.28, + "uvIndex": 0, + "visibility": 20272.0, + "windDirection": 161, + "windGust": 13.57, + "windSpeed": 5.66 + }, + { + "forecastStart": "2023-09-13T14:00:00Z", + "cloudCover": 0.84, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.9, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.99, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.91, + "temperatureApparent": 26.36, + "temperatureDewPoint": 22.17, + "uvIndex": 0, + "visibility": 20994.0, + "windDirection": 159, + "windGust": 15.07, + "windSpeed": 5.83 + }, + { + "forecastStart": "2023-09-13T15:00:00Z", + "cloudCover": 0.86, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.91, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.95, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.69, + "temperatureApparent": 26.12, + "temperatureDewPoint": 22.17, + "uvIndex": 0, + "visibility": 21105.0, + "windDirection": 158, + "windGust": 16.06, + "windSpeed": 5.93 + }, + { + "forecastStart": "2023-09-13T16:00:00Z", + "cloudCover": 0.88, + "conditionCode": "Cloudy", + "daylight": false, + "humidity": 0.92, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.9, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.35, + "temperatureApparent": 25.67, + "temperatureDewPoint": 21.98, + "uvIndex": 0, + "visibility": 20061.0, + "windDirection": 153, + "windGust": 16.05, + "windSpeed": 5.75 + }, + { + "forecastStart": "2023-09-13T17:00:00Z", + "cloudCover": 0.9, + "conditionCode": "Cloudy", + "daylight": false, + "humidity": 0.92, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.85, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.14, + "temperatureApparent": 25.39, + "temperatureDewPoint": 21.84, + "uvIndex": 0, + "visibility": 18402.0, + "windDirection": 150, + "windGust": 15.52, + "windSpeed": 5.49 + }, + { + "forecastStart": "2023-09-13T18:00:00Z", + "cloudCover": 0.92, + "conditionCode": "Cloudy", + "daylight": false, + "humidity": 0.93, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.87, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 22.99, + "temperatureApparent": 25.2, + "temperatureDewPoint": 21.76, + "uvIndex": 0, + "visibility": 17039.0, + "windDirection": 149, + "windGust": 15.01, + "windSpeed": 5.32 + }, + { + "forecastStart": "2023-09-13T19:00:00Z", + "cloudCover": 0.9, + "conditionCode": "Cloudy", + "daylight": false, + "humidity": 0.94, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1013.01, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 22.79, + "temperatureApparent": 24.96, + "temperatureDewPoint": 21.7, + "uvIndex": 0, + "visibility": 16081.0, + "windDirection": 147, + "windGust": 14.39, + "windSpeed": 5.33 + }, + { + "forecastStart": "2023-09-13T20:00:00Z", + "cloudCover": 0.89, + "conditionCode": "Cloudy", + "daylight": false, + "humidity": 0.94, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1013.22, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 22.63, + "temperatureApparent": 24.75, + "temperatureDewPoint": 21.61, + "uvIndex": 0, + "visibility": 15426.0, + "windDirection": 147, + "windGust": 13.79, + "windSpeed": 5.43 + }, + { + "forecastStart": "2023-09-13T21:00:00Z", + "cloudCover": 0.86, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.92, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1013.41, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.1, + "temperatureApparent": 25.33, + "temperatureDewPoint": 21.8, + "uvIndex": 0, + "visibility": 15660.0, + "windDirection": 147, + "windGust": 14.12, + "windSpeed": 5.52 + }, + { + "forecastStart": "2023-09-13T22:00:00Z", + "cloudCover": 0.77, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.88, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1013.59, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.26, + "temperatureApparent": 26.73, + "temperatureDewPoint": 22.14, + "uvIndex": 1, + "visibility": 17559.0, + "windDirection": 147, + "windGust": 16.14, + "windSpeed": 5.58 + }, + { + "forecastStart": "2023-09-13T23:00:00Z", + "cloudCover": 0.65, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.82, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1013.74, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 25.67, + "temperatureApparent": 28.37, + "temperatureDewPoint": 22.37, + "uvIndex": 2, + "visibility": 20352.0, + "windDirection": 146, + "windGust": 19.09, + "windSpeed": 5.62 + }, + { + "forecastStart": "2023-09-14T00:00:00Z", + "cloudCover": 0.58, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.76, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1013.78, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 27.37, + "temperatureApparent": 30.48, + "temperatureDewPoint": 22.85, + "uvIndex": 4, + "visibility": 22307.0, + "windDirection": 143, + "windGust": 21.6, + "windSpeed": 5.58 + }, + { + "forecastStart": "2023-09-14T01:00:00Z", + "cloudCover": 0.54, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.72, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1013.61, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 28.73, + "temperatureApparent": 32.18, + "temperatureDewPoint": 23.18, + "uvIndex": 5, + "visibility": 22630.0, + "windDirection": 138, + "windGust": 23.36, + "windSpeed": 5.34 + }, + { + "forecastStart": "2023-09-14T02:00:00Z", + "cloudCover": 0.54, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.68, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1013.32, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 29.87, + "temperatureApparent": 33.5, + "temperatureDewPoint": 23.23, + "uvIndex": 6, + "visibility": 22159.0, + "windDirection": 111, + "windGust": 24.72, + "windSpeed": 4.99 + }, + { + "forecastStart": "2023-09-14T03:00:00Z", + "cloudCover": 0.56, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.65, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1013.04, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 30.66, + "temperatureApparent": 34.42, + "temperatureDewPoint": 23.28, + "uvIndex": 6, + "visibility": 21610.0, + "windDirection": 354, + "windGust": 25.23, + "windSpeed": 4.74 + }, + { + "forecastStart": "2023-09-14T04:00:00Z", + "cloudCover": 0.58, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.64, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.77, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 30.98, + "temperatureApparent": 34.85, + "temperatureDewPoint": 23.37, + "uvIndex": 6, + "visibility": 21210.0, + "windDirection": 341, + "windGust": 24.6, + "windSpeed": 4.79 + }, + { + "forecastStart": "2023-09-14T05:00:00Z", + "cloudCover": 0.6, + "conditionCode": "Drizzle", + "daylight": true, + "humidity": 0.64, + "precipitationAmount": 0.2, + "precipitationIntensity": 0.2, + "precipitationChance": 0.15, + "precipitationType": "rain", + "pressure": 1012.53, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 30.73, + "temperatureApparent": 34.48, + "temperatureDewPoint": 23.24, + "uvIndex": 5, + "visibility": 20870.0, + "windDirection": 336, + "windGust": 23.28, + "windSpeed": 5.07 + }, + { + "forecastStart": "2023-09-14T06:00:00Z", + "cloudCover": 0.59, + "conditionCode": "Drizzle", + "daylight": true, + "humidity": 0.66, + "precipitationAmount": 0.2, + "precipitationIntensity": 0.2, + "precipitationChance": 0.14, + "precipitationType": "rain", + "pressure": 1012.49, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 30.23, + "temperatureApparent": 33.82, + "temperatureDewPoint": 23.07, + "uvIndex": 3, + "visibility": 20831.0, + "windDirection": 336, + "windGust": 22.05, + "windSpeed": 5.34 + }, + { + "forecastStart": "2023-09-14T07:00:00Z", + "cloudCover": 0.53, + "conditionCode": "Drizzle", + "daylight": true, + "humidity": 0.68, + "precipitationAmount": 0.2, + "precipitationIntensity": 0.2, + "precipitationChance": 0.4, + "precipitationType": "rain", + "pressure": 1012.73, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 29.47, + "temperatureApparent": 32.94, + "temperatureDewPoint": 23.04, + "uvIndex": 2, + "visibility": 21284.0, + "windDirection": 339, + "windGust": 21.18, + "windSpeed": 5.63 + }, + { + "forecastStart": "2023-09-14T08:00:00Z", + "cloudCover": 0.43, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.72, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.45, + "precipitationType": "clear", + "pressure": 1013.16, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 28.35, + "temperatureApparent": 31.56, + "temperatureDewPoint": 22.82, + "uvIndex": 0, + "visibility": 21999.0, + "windDirection": 342, + "windGust": 20.35, + "windSpeed": 5.93 + }, + { + "forecastStart": "2023-09-14T09:00:00Z", + "cloudCover": 0.35, + "conditionCode": "MostlyClear", + "daylight": true, + "humidity": 0.76, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.4, + "precipitationType": "clear", + "pressure": 1013.62, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 27.11, + "temperatureApparent": 30.03, + "temperatureDewPoint": 22.51, + "uvIndex": 0, + "visibility": 22578.0, + "windDirection": 347, + "windGust": 19.42, + "windSpeed": 5.95 + }, + { + "forecastStart": "2023-09-14T10:00:00Z", + "cloudCover": 0.32, + "conditionCode": "MostlyClear", + "daylight": false, + "humidity": 0.79, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.4, + "precipitationType": "clear", + "pressure": 1014.09, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 26.27, + "temperatureApparent": 29.04, + "temperatureDewPoint": 22.38, + "uvIndex": 0, + "visibility": 22916.0, + "windDirection": 348, + "windGust": 18.19, + "windSpeed": 5.31 + }, + { + "forecastStart": "2023-09-14T11:00:00Z", + "cloudCover": 0.31, + "conditionCode": "MostlyClear", + "daylight": false, + "humidity": 0.83, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.4, + "precipitationType": "clear", + "pressure": 1014.56, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 25.53, + "temperatureApparent": 28.23, + "temperatureDewPoint": 22.39, + "uvIndex": 0, + "visibility": 23051.0, + "windDirection": 177, + "windGust": 16.79, + "windSpeed": 4.28 + }, + { + "forecastStart": "2023-09-14T12:00:00Z", + "cloudCover": 0.31, + "conditionCode": "MostlyClear", + "daylight": false, + "humidity": 0.86, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.4, + "precipitationType": "clear", + "pressure": 1014.87, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.9, + "temperatureApparent": 27.51, + "temperatureDewPoint": 22.32, + "uvIndex": 0, + "visibility": 22814.0, + "windDirection": 171, + "windGust": 15.61, + "windSpeed": 3.72 + }, + { + "forecastStart": "2023-09-14T13:00:00Z", + "cloudCover": 0.31, + "conditionCode": "MostlyClear", + "daylight": false, + "humidity": 0.88, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.4, + "precipitationType": "clear", + "pressure": 1014.91, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.17, + "temperatureApparent": 26.6, + "temperatureDewPoint": 22.06, + "uvIndex": 0, + "visibility": 21946.0, + "windDirection": 171, + "windGust": 14.7, + "windSpeed": 4.11 + }, + { + "forecastStart": "2023-09-14T14:00:00Z", + "cloudCover": 0.32, + "conditionCode": "MostlyClear", + "daylight": false, + "humidity": 0.9, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.4, + "precipitationType": "clear", + "pressure": 1014.8, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.6, + "temperatureApparent": 25.9, + "temperatureDewPoint": 21.86, + "uvIndex": 0, + "visibility": 20560.0, + "windDirection": 171, + "windGust": 13.81, + "windSpeed": 4.97 + }, + { + "forecastStart": "2023-09-14T15:00:00Z", + "cloudCover": 0.34, + "conditionCode": "MostlyClear", + "daylight": false, + "humidity": 0.92, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.4, + "precipitationType": "clear", + "pressure": 1014.66, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.1, + "temperatureApparent": 25.28, + "temperatureDewPoint": 21.66, + "uvIndex": 0, + "visibility": 19040.0, + "windDirection": 170, + "windGust": 12.88, + "windSpeed": 5.57 + }, + { + "forecastStart": "2023-09-14T16:00:00Z", + "cloudCover": 0.37, + "conditionCode": "MostlyClear", + "daylight": false, + "humidity": 0.93, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.4, + "precipitationType": "clear", + "pressure": 1014.54, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 22.69, + "temperatureApparent": 24.76, + "temperatureDewPoint": 21.46, + "uvIndex": 0, + "visibility": 17747.0, + "windDirection": 168, + "windGust": 12.0, + "windSpeed": 5.62 + }, + { + "forecastStart": "2023-09-14T17:00:00Z", + "cloudCover": 0.39, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.94, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.4, + "precipitationType": "clear", + "pressure": 1014.45, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 22.4, + "temperatureApparent": 24.4, + "temperatureDewPoint": 21.32, + "uvIndex": 0, + "visibility": 16872.0, + "windDirection": 165, + "windGust": 11.43, + "windSpeed": 5.48 + }, + { + "forecastStart": "2023-09-14T18:00:00Z", + "cloudCover": 0.4, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.93, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.44, + "precipitationType": "clear", + "pressure": 1014.45, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 22.58, + "temperatureApparent": 24.63, + "temperatureDewPoint": 21.43, + "uvIndex": 0, + "visibility": 16548.0, + "windDirection": 162, + "windGust": 11.42, + "windSpeed": 5.38 + }, + { + "forecastStart": "2023-09-14T19:00:00Z", + "cloudCover": 0.4, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.92, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.52, + "precipitationType": "clear", + "pressure": 1014.63, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 22.88, + "temperatureApparent": 25.01, + "temperatureDewPoint": 21.58, + "uvIndex": 0, + "visibility": 16862.0, + "windDirection": 161, + "windGust": 12.15, + "windSpeed": 5.39 + }, + { + "forecastStart": "2023-09-14T20:00:00Z", + "cloudCover": 0.38, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.91, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.51, + "precipitationType": "clear", + "pressure": 1014.91, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.36, + "temperatureApparent": 25.6, + "temperatureDewPoint": 21.77, + "uvIndex": 0, + "visibility": 17845.0, + "windDirection": 159, + "windGust": 13.54, + "windSpeed": 5.45 + }, + { + "forecastStart": "2023-09-14T21:00:00Z", + "cloudCover": 0.36, + "conditionCode": "MostlyClear", + "daylight": true, + "humidity": 0.88, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.42, + "precipitationType": "clear", + "pressure": 1015.18, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.2, + "temperatureApparent": 26.61, + "temperatureDewPoint": 22.01, + "uvIndex": 0, + "visibility": 19537.0, + "windDirection": 158, + "windGust": 15.48, + "windSpeed": 5.62 + }, + { + "forecastStart": "2023-09-14T22:00:00Z", + "cloudCover": 0.32, + "conditionCode": "MostlyClear", + "daylight": true, + "humidity": 0.83, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.29, + "precipitationType": "clear", + "pressure": 1015.4, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 25.68, + "temperatureApparent": 28.46, + "temperatureDewPoint": 22.54, + "uvIndex": 1, + "visibility": 21828.0, + "windDirection": 158, + "windGust": 17.86, + "windSpeed": 5.84 + }, + { + "forecastStart": "2023-09-14T23:00:00Z", + "cloudCover": 0.3, + "conditionCode": "MostlyClear", + "daylight": true, + "humidity": 0.77, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1015.54, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 27.19, + "temperatureApparent": 30.28, + "temperatureDewPoint": 22.85, + "uvIndex": 2, + "visibility": 24036.0, + "windDirection": 155, + "windGust": 20.19, + "windSpeed": 6.09 + }, + { + "forecastStart": "2023-09-15T00:00:00Z", + "cloudCover": 0.3, + "conditionCode": "MostlyClear", + "daylight": true, + "humidity": 0.73, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1015.55, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 28.65, + "temperatureApparent": 32.15, + "temperatureDewPoint": 23.29, + "uvIndex": 4, + "visibility": 25340.0, + "windDirection": 152, + "windGust": 21.83, + "windSpeed": 6.42 + }, + { + "forecastStart": "2023-09-15T01:00:00Z", + "cloudCover": 0.34, + "conditionCode": "MostlyClear", + "daylight": true, + "humidity": 0.7, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1015.35, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 29.65, + "temperatureApparent": 33.4, + "temperatureDewPoint": 23.5, + "uvIndex": 6, + "visibility": 25384.0, + "windDirection": 144, + "windGust": 22.56, + "windSpeed": 6.91 + }, + { + "forecastStart": "2023-09-15T02:00:00Z", + "cloudCover": 0.41, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.67, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1015.0, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 30.38, + "temperatureApparent": 34.24, + "temperatureDewPoint": 23.52, + "uvIndex": 7, + "visibility": 24635.0, + "windDirection": 336, + "windGust": 22.83, + "windSpeed": 7.47 + }, + { + "forecastStart": "2023-09-15T03:00:00Z", + "cloudCover": 0.46, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.65, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1014.62, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 30.93, + "temperatureApparent": 34.88, + "temperatureDewPoint": 23.53, + "uvIndex": 7, + "visibility": 23513.0, + "windDirection": 336, + "windGust": 22.98, + "windSpeed": 7.95 + }, + { + "forecastStart": "2023-09-15T04:00:00Z", + "cloudCover": 0.46, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.64, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1014.25, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 31.31, + "temperatureApparent": 35.35, + "temperatureDewPoint": 23.58, + "uvIndex": 6, + "visibility": 22350.0, + "windDirection": 341, + "windGust": 23.21, + "windSpeed": 8.44 + }, + { + "forecastStart": "2023-09-15T05:00:00Z", + "cloudCover": 0.44, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.64, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1013.95, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 31.46, + "temperatureApparent": 35.61, + "temperatureDewPoint": 23.72, + "uvIndex": 5, + "visibility": 21383.0, + "windDirection": 344, + "windGust": 23.46, + "windSpeed": 8.95 + }, + { + "forecastStart": "2023-09-15T06:00:00Z", + "cloudCover": 0.42, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.64, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1013.83, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 31.09, + "temperatureApparent": 35.1, + "temperatureDewPoint": 23.58, + "uvIndex": 3, + "visibility": 20900.0, + "windDirection": 347, + "windGust": 23.64, + "windSpeed": 9.13 + }, + { + "forecastStart": "2023-09-15T07:00:00Z", + "cloudCover": 0.41, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.66, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1013.96, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 30.33, + "temperatureApparent": 34.1, + "temperatureDewPoint": 23.37, + "uvIndex": 2, + "visibility": 21046.0, + "windDirection": 350, + "windGust": 23.66, + "windSpeed": 8.78 + }, + { + "forecastStart": "2023-09-15T08:00:00Z", + "cloudCover": 0.4, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.7, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1014.25, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 28.98, + "temperatureApparent": 32.39, + "temperatureDewPoint": 23.05, + "uvIndex": 0, + "visibility": 21562.0, + "windDirection": 356, + "windGust": 23.51, + "windSpeed": 8.13 + }, + { + "forecastStart": "2023-09-15T09:00:00Z", + "cloudCover": 0.41, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.74, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1014.61, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 27.94, + "temperatureApparent": 31.13, + "temperatureDewPoint": 22.87, + "uvIndex": 0, + "visibility": 22131.0, + "windDirection": 3, + "windGust": 23.21, + "windSpeed": 7.48 + }, + { + "forecastStart": "2023-09-15T10:00:00Z", + "cloudCover": 0.43, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.78, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1015.02, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 26.95, + "temperatureApparent": 29.98, + "temperatureDewPoint": 22.79, + "uvIndex": 0, + "visibility": 22382.0, + "windDirection": 20, + "windGust": 22.68, + "windSpeed": 6.83 + }, + { + "forecastStart": "2023-09-15T11:00:00Z", + "cloudCover": 0.46, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.82, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1015.43, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 26.21, + "temperatureApparent": 29.17, + "temperatureDewPoint": 22.81, + "uvIndex": 0, + "visibility": 22366.0, + "windDirection": 129, + "windGust": 22.04, + "windSpeed": 6.1 + }, + { + "forecastStart": "2023-09-15T12:00:00Z", + "cloudCover": 0.48, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.84, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1015.71, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 25.56, + "temperatureApparent": 28.42, + "temperatureDewPoint": 22.73, + "uvIndex": 0, + "visibility": 22383.0, + "windDirection": 159, + "windGust": 21.64, + "windSpeed": 5.6 + }, + { + "forecastStart": "2023-09-15T13:00:00Z", + "cloudCover": 0.65, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.88, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1015.52, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 25.22, + "temperatureApparent": 28.24, + "temperatureDewPoint": 23.16, + "uvIndex": 0, + "visibility": 21966.0, + "windDirection": 164, + "windGust": 16.35, + "windSpeed": 5.58 + }, + { + "forecastStart": "2023-09-15T14:00:00Z", + "cloudCover": 0.65, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.9, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1015.37, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.61, + "temperatureApparent": 27.42, + "temperatureDewPoint": 22.86, + "uvIndex": 0, + "visibility": 22357.0, + "windDirection": 168, + "windGust": 17.11, + "windSpeed": 5.79 + }, + { + "forecastStart": "2023-09-15T15:00:00Z", + "cloudCover": 0.65, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.92, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1015.21, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.16, + "temperatureApparent": 26.86, + "temperatureDewPoint": 22.71, + "uvIndex": 0, + "visibility": 22189.0, + "windDirection": 182, + "windGust": 17.32, + "windSpeed": 5.77 + }, + { + "forecastStart": "2023-09-15T16:00:00Z", + "cloudCover": 0.65, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.93, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1015.07, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.78, + "temperatureApparent": 26.4, + "temperatureDewPoint": 22.61, + "uvIndex": 0, + "visibility": 21374.0, + "windDirection": 201, + "windGust": 16.6, + "windSpeed": 5.27 + }, + { + "forecastStart": "2023-09-15T17:00:00Z", + "cloudCover": 0.66, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.94, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1014.95, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.48, + "temperatureApparent": 26.01, + "temperatureDewPoint": 22.46, + "uvIndex": 0, + "visibility": 20612.0, + "windDirection": 219, + "windGust": 15.52, + "windSpeed": 4.62 + }, + { + "forecastStart": "2023-09-15T18:00:00Z", + "cloudCover": 0.66, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.94, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1014.88, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.29, + "temperatureApparent": 25.72, + "temperatureDewPoint": 22.27, + "uvIndex": 0, + "visibility": 20500.0, + "windDirection": 216, + "windGust": 14.64, + "windSpeed": 4.32 + }, + { + "forecastStart": "2023-09-15T19:00:00Z", + "cloudCover": 0.66, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.94, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1014.91, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.48, + "temperatureApparent": 25.98, + "temperatureDewPoint": 22.39, + "uvIndex": 0, + "visibility": 21319.0, + "windDirection": 198, + "windGust": 14.06, + "windSpeed": 4.73 + }, + { + "forecastStart": "2023-09-15T20:00:00Z", + "cloudCover": 0.66, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.92, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1014.99, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.8, + "temperatureApparent": 26.34, + "temperatureDewPoint": 22.42, + "uvIndex": 0, + "visibility": 22776.0, + "windDirection": 189, + "windGust": 13.7, + "windSpeed": 5.49 + }, + { + "forecastStart": "2023-09-15T21:00:00Z", + "cloudCover": 0.64, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.89, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1015.07, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.43, + "temperatureApparent": 27.08, + "temperatureDewPoint": 22.53, + "uvIndex": 0, + "visibility": 24606.0, + "windDirection": 183, + "windGust": 13.77, + "windSpeed": 5.95 + }, + { + "forecastStart": "2023-09-15T22:00:00Z", + "cloudCover": 0.59, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.84, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1015.12, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 25.47, + "temperatureApparent": 28.28, + "temperatureDewPoint": 22.65, + "uvIndex": 1, + "visibility": 26540.0, + "windDirection": 179, + "windGust": 14.38, + "windSpeed": 5.77 + }, + { + "forecastStart": "2023-09-15T23:00:00Z", + "cloudCover": 0.52, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.79, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1015.13, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 26.85, + "temperatureApparent": 29.91, + "temperatureDewPoint": 22.86, + "uvIndex": 2, + "visibility": 28300.0, + "windDirection": 170, + "windGust": 15.2, + "windSpeed": 5.27 + }, + { + "forecastStart": "2023-09-16T00:00:00Z", + "cloudCover": 0.44, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.74, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1015.04, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 28.02, + "temperatureApparent": 31.22, + "temperatureDewPoint": 22.86, + "uvIndex": 4, + "visibility": 29608.0, + "windDirection": 155, + "windGust": 15.85, + "windSpeed": 4.76 + }, + { + "forecastStart": "2023-09-16T01:00:00Z", + "cloudCover": 0.24, + "conditionCode": "MostlyClear", + "daylight": true, + "humidity": 0.68, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1014.52, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 29.24, + "temperatureApparent": 32.46, + "temperatureDewPoint": 22.63, + "uvIndex": 6, + "visibility": 30511.0, + "windDirection": 110, + "windGust": 16.27, + "windSpeed": 6.81 + }, + { + "forecastStart": "2023-09-16T02:00:00Z", + "cloudCover": 0.16, + "conditionCode": "MostlyClear", + "daylight": true, + "humidity": 0.63, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1014.01, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 30.25, + "temperatureApparent": 33.46, + "temperatureDewPoint": 22.37, + "uvIndex": 8, + "visibility": 31232.0, + "windDirection": 30, + "windGust": 16.55, + "windSpeed": 6.86 + }, + { + "forecastStart": "2023-09-16T03:00:00Z", + "cloudCover": 0.1, + "conditionCode": "Clear", + "daylight": true, + "humidity": 0.59, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1013.45, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 31.05, + "temperatureApparent": 34.18, + "temperatureDewPoint": 22.04, + "uvIndex": 8, + "visibility": 31751.0, + "windDirection": 17, + "windGust": 16.52, + "windSpeed": 6.8 + }, + { + "forecastStart": "2023-09-16T04:00:00Z", + "cloudCover": 0.1, + "conditionCode": "Clear", + "daylight": true, + "humidity": 0.57, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.89, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 31.54, + "temperatureApparent": 34.67, + "temperatureDewPoint": 21.93, + "uvIndex": 8, + "visibility": 32057.0, + "windDirection": 17, + "windGust": 16.08, + "windSpeed": 6.62 + }, + { + "forecastStart": "2023-09-16T05:00:00Z", + "cloudCover": 0.1, + "conditionCode": "Clear", + "daylight": true, + "humidity": 0.56, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.39, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 31.77, + "temperatureApparent": 34.92, + "temperatureDewPoint": 21.91, + "uvIndex": 6, + "visibility": 32148.0, + "windDirection": 20, + "windGust": 15.48, + "windSpeed": 6.45 + }, + { + "forecastStart": "2023-09-16T06:00:00Z", + "cloudCover": 0.1, + "conditionCode": "Clear", + "daylight": true, + "humidity": 0.56, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.11, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 31.44, + "temperatureApparent": 34.45, + "temperatureDewPoint": 21.72, + "uvIndex": 4, + "visibility": 32012.0, + "windDirection": 26, + "windGust": 15.08, + "windSpeed": 6.43 + }, + { + "forecastStart": "2023-09-16T07:00:00Z", + "cloudCover": 0.07, + "conditionCode": "Clear", + "daylight": true, + "humidity": 0.59, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.15, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 30.69, + "temperatureApparent": 33.61, + "temperatureDewPoint": 21.71, + "uvIndex": 2, + "visibility": 31608.0, + "windDirection": 39, + "windGust": 14.88, + "windSpeed": 6.61 + }, + { + "forecastStart": "2023-09-16T08:00:00Z", + "cloudCover": 0.02, + "conditionCode": "Clear", + "daylight": true, + "humidity": 0.63, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.41, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 29.61, + "temperatureApparent": 32.49, + "temperatureDewPoint": 21.87, + "uvIndex": 0, + "visibility": 30972.0, + "windDirection": 72, + "windGust": 14.82, + "windSpeed": 6.95 + }, + { + "forecastStart": "2023-09-16T09:00:00Z", + "cloudCover": 0.02, + "conditionCode": "Clear", + "daylight": true, + "humidity": 0.68, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.75, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 28.54, + "temperatureApparent": 31.45, + "temperatureDewPoint": 22.15, + "uvIndex": 0, + "visibility": 30211.0, + "windDirection": 116, + "windGust": 15.13, + "windSpeed": 7.45 + }, + { + "forecastStart": "2023-09-16T10:00:00Z", + "cloudCover": 0.13, + "conditionCode": "MostlyClear", + "daylight": false, + "humidity": 0.73, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1013.13, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 27.57, + "temperatureApparent": 30.46, + "temperatureDewPoint": 22.34, + "uvIndex": 0, + "visibility": 29403.0, + "windDirection": 140, + "windGust": 16.09, + "windSpeed": 8.15 + }, + { + "forecastStart": "2023-09-16T11:00:00Z", + "cloudCover": 0.31, + "conditionCode": "MostlyClear", + "daylight": false, + "humidity": 0.78, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1013.47, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 26.87, + "temperatureApparent": 29.82, + "temperatureDewPoint": 22.62, + "uvIndex": 0, + "visibility": 28466.0, + "windDirection": 149, + "windGust": 17.37, + "windSpeed": 8.87 + }, + { + "forecastStart": "2023-09-16T12:00:00Z", + "cloudCover": 0.45, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.82, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1013.6, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 26.29, + "temperatureApparent": 29.3, + "temperatureDewPoint": 22.89, + "uvIndex": 0, + "visibility": 27272.0, + "windDirection": 155, + "windGust": 18.29, + "windSpeed": 9.21 + }, + { + "forecastStart": "2023-09-16T13:00:00Z", + "cloudCover": 0.51, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.85, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1013.41, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 25.74, + "temperatureApparent": 28.73, + "temperatureDewPoint": 22.99, + "uvIndex": 0, + "visibility": 25405.0, + "windDirection": 159, + "windGust": 18.49, + "windSpeed": 8.96 + }, + { + "forecastStart": "2023-09-16T14:00:00Z", + "cloudCover": 0.55, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.88, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1013.01, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 25.02, + "temperatureApparent": 27.86, + "temperatureDewPoint": 22.82, + "uvIndex": 0, + "visibility": 22840.0, + "windDirection": 162, + "windGust": 18.47, + "windSpeed": 8.45 + }, + { + "forecastStart": "2023-09-16T15:00:00Z", + "cloudCover": 0.59, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.9, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.55, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.48, + "temperatureApparent": 27.22, + "temperatureDewPoint": 22.73, + "uvIndex": 0, + "visibility": 20049.0, + "windDirection": 162, + "windGust": 18.79, + "windSpeed": 8.1 + }, + { + "forecastStart": "2023-09-16T16:00:00Z", + "cloudCover": 0.65, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.92, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.1, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.03, + "temperatureApparent": 26.69, + "temperatureDewPoint": 22.65, + "uvIndex": 0, + "visibility": 17483.0, + "windDirection": 162, + "windGust": 19.81, + "windSpeed": 8.15 + }, + { + "forecastStart": "2023-09-16T17:00:00Z", + "cloudCover": 0.7, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.94, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.68, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.69, + "temperatureApparent": 26.29, + "temperatureDewPoint": 22.6, + "uvIndex": 0, + "visibility": 15558.0, + "windDirection": 161, + "windGust": 20.96, + "windSpeed": 8.3 + }, + { + "forecastStart": "2023-09-16T18:00:00Z", + "cloudCover": 0.72, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.94, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.39, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.5, + "temperatureApparent": 26.01, + "temperatureDewPoint": 22.41, + "uvIndex": 0, + "visibility": 14707.0, + "windDirection": 159, + "windGust": 21.41, + "windSpeed": 8.24 + }, + { + "forecastStart": "2023-09-16T19:00:00Z", + "cloudCover": 0.7, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.93, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.29, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.75, + "temperatureApparent": 26.33, + "temperatureDewPoint": 22.51, + "uvIndex": 0, + "visibility": 15332.0, + "windDirection": 159, + "windGust": 20.42, + "windSpeed": 7.62 + }, + { + "forecastStart": "2023-09-16T20:00:00Z", + "cloudCover": 0.65, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.91, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.31, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.19, + "temperatureApparent": 26.84, + "temperatureDewPoint": 22.59, + "uvIndex": 0, + "visibility": 17205.0, + "windDirection": 158, + "windGust": 18.61, + "windSpeed": 6.66 + }, + { + "forecastStart": "2023-09-16T21:00:00Z", + "cloudCover": 0.58, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.87, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.37, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.92, + "temperatureApparent": 27.67, + "temperatureDewPoint": 22.64, + "uvIndex": 0, + "visibility": 19811.0, + "windDirection": 158, + "windGust": 17.14, + "windSpeed": 5.86 + }, + { + "forecastStart": "2023-09-16T22:00:00Z", + "cloudCover": 0.48, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.82, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.46, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 26.0, + "temperatureApparent": 28.85, + "temperatureDewPoint": 22.61, + "uvIndex": 1, + "visibility": 22602.0, + "windDirection": 161, + "windGust": 16.78, + "windSpeed": 5.5 + }, + { + "forecastStart": "2023-09-16T23:00:00Z", + "cloudCover": 0.39, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.76, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.51, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 27.47, + "temperatureApparent": 30.6, + "temperatureDewPoint": 22.86, + "uvIndex": 2, + "visibility": 24958.0, + "windDirection": 165, + "windGust": 17.21, + "windSpeed": 5.56 + }, + { + "forecastStart": "2023-09-17T00:00:00Z", + "cloudCover": 0.33, + "conditionCode": "MostlyClear", + "daylight": true, + "humidity": 0.71, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.39, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 28.49, + "temperatureApparent": 31.7, + "temperatureDewPoint": 22.77, + "uvIndex": 4, + "visibility": 26230.0, + "windDirection": 174, + "windGust": 17.96, + "windSpeed": 6.04 + }, + { + "forecastStart": "2023-09-17T01:00:00Z", + "cloudCover": 0.3, + "conditionCode": "MostlyClear", + "daylight": true, + "humidity": 0.68, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.98, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 29.35, + "temperatureApparent": 32.64, + "temperatureDewPoint": 22.73, + "uvIndex": 6, + "visibility": 26296.0, + "windDirection": 192, + "windGust": 19.15, + "windSpeed": 7.23 + }, + { + "forecastStart": "2023-09-17T02:00:00Z", + "cloudCover": 0.29, + "conditionCode": "MostlyClear", + "daylight": true, + "humidity": 0.65, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.38, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 30.14, + "temperatureApparent": 33.56, + "temperatureDewPoint": 22.78, + "uvIndex": 7, + "visibility": 25582.0, + "windDirection": 225, + "windGust": 20.89, + "windSpeed": 8.9 + }, + { + "forecastStart": "2023-09-17T03:00:00Z", + "cloudCover": 0.3, + "conditionCode": "MostlyClear", + "daylight": true, + "humidity": 0.63, + "precipitationAmount": 0.3, + "precipitationIntensity": 0.3, + "precipitationChance": 0.09, + "precipitationType": "rain", + "pressure": 1009.75, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 30.66, + "temperatureApparent": 34.13, + "temperatureDewPoint": 22.76, + "uvIndex": 8, + "visibility": 24257.0, + "windDirection": 264, + "windGust": 22.67, + "windSpeed": 10.27 + }, + { + "forecastStart": "2023-09-17T04:00:00Z", + "cloudCover": 0.37, + "conditionCode": "MostlyClear", + "daylight": true, + "humidity": 0.62, + "precipitationAmount": 0.4, + "precipitationIntensity": 0.4, + "precipitationChance": 0.1, + "precipitationType": "rain", + "pressure": 1009.18, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 30.54, + "temperatureApparent": 33.88, + "temperatureDewPoint": 22.54, + "uvIndex": 7, + "visibility": 22565.0, + "windDirection": 293, + "windGust": 23.93, + "windSpeed": 10.82 + }, + { + "forecastStart": "2023-09-17T05:00:00Z", + "cloudCover": 0.45, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.63, + "precipitationAmount": 0.6, + "precipitationIntensity": 0.6, + "precipitationChance": 0.12, + "precipitationType": "rain", + "pressure": 1008.71, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 30.15, + "temperatureApparent": 33.36, + "temperatureDewPoint": 22.38, + "uvIndex": 5, + "visibility": 20796.0, + "windDirection": 308, + "windGust": 24.39, + "windSpeed": 10.72 + }, + { + "forecastStart": "2023-09-17T06:00:00Z", + "cloudCover": 0.5, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.64, + "precipitationAmount": 0.7, + "precipitationIntensity": 0.7, + "precipitationChance": 0.14, + "precipitationType": "rain", + "pressure": 1008.46, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 29.62, + "temperatureApparent": 32.67, + "temperatureDewPoint": 22.19, + "uvIndex": 3, + "visibility": 19195.0, + "windDirection": 312, + "windGust": 23.9, + "windSpeed": 10.28 + }, + { + "forecastStart": "2023-09-17T07:00:00Z", + "cloudCover": 0.47, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.67, + "precipitationAmount": 0.7, + "precipitationIntensity": 0.7, + "precipitationChance": 0.14, + "precipitationType": "rain", + "pressure": 1008.53, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 28.91, + "temperatureApparent": 31.84, + "temperatureDewPoint": 22.12, + "uvIndex": 1, + "visibility": 17604.0, + "windDirection": 312, + "windGust": 22.3, + "windSpeed": 9.59 + }, + { + "forecastStart": "2023-09-17T08:00:00Z", + "cloudCover": 0.41, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.7, + "precipitationAmount": 0.6, + "precipitationIntensity": 0.6, + "precipitationChance": 0.15, + "precipitationType": "rain", + "pressure": 1008.82, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 27.91, + "temperatureApparent": 30.64, + "temperatureDewPoint": 21.93, + "uvIndex": 0, + "visibility": 15869.0, + "windDirection": 305, + "windGust": 19.73, + "windSpeed": 8.58 + }, + { + "forecastStart": "2023-09-17T09:00:00Z", + "cloudCover": 0.35, + "conditionCode": "MostlyClear", + "daylight": false, + "humidity": 0.74, + "precipitationAmount": 0.5, + "precipitationIntensity": 0.5, + "precipitationChance": 0.15, + "precipitationType": "rain", + "pressure": 1009.21, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 26.99, + "temperatureApparent": 29.64, + "temperatureDewPoint": 21.96, + "uvIndex": 0, + "visibility": 14244.0, + "windDirection": 291, + "windGust": 16.49, + "windSpeed": 7.34 + }, + { + "forecastStart": "2023-09-17T10:00:00Z", + "cloudCover": 0.33, + "conditionCode": "MostlyClear", + "daylight": false, + "humidity": 0.78, + "precipitationAmount": 0.4, + "precipitationIntensity": 0.4, + "precipitationChance": 0.14, + "precipitationType": "rain", + "pressure": 1009.65, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 26.1, + "temperatureApparent": 28.63, + "temperatureDewPoint": 21.88, + "uvIndex": 0, + "visibility": 12808.0, + "windDirection": 257, + "windGust": 12.71, + "windSpeed": 5.91 + }, + { + "forecastStart": "2023-09-17T11:00:00Z", + "cloudCover": 0.34, + "conditionCode": "MostlyClear", + "daylight": false, + "humidity": 0.82, + "precipitationAmount": 0.3, + "precipitationIntensity": 0.3, + "precipitationChance": 0.14, + "precipitationType": "rain", + "pressure": 1010.04, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 25.29, + "temperatureApparent": 27.76, + "temperatureDewPoint": 21.92, + "uvIndex": 0, + "visibility": 11601.0, + "windDirection": 212, + "windGust": 9.16, + "windSpeed": 4.54 + }, + { + "forecastStart": "2023-09-17T12:00:00Z", + "cloudCover": 0.36, + "conditionCode": "MostlyClear", + "daylight": false, + "humidity": 0.85, + "precipitationAmount": 0.3, + "precipitationIntensity": 0.3, + "precipitationChance": 0.28, + "precipitationType": "rain", + "pressure": 1010.24, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.65, + "temperatureApparent": 27.06, + "temperatureDewPoint": 21.92, + "uvIndex": 0, + "visibility": 10807.0, + "windDirection": 192, + "windGust": 7.09, + "windSpeed": 3.62 + }, + { + "forecastStart": "2023-09-17T13:00:00Z", + "cloudCover": 0.4, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.88, + "precipitationAmount": 0.3, + "precipitationIntensity": 0.3, + "precipitationChance": 0.3, + "precipitationType": "rain", + "pressure": 1010.15, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.15, + "temperatureApparent": 26.54, + "temperatureDewPoint": 21.96, + "uvIndex": 0, + "visibility": 10514.0, + "windDirection": 185, + "windGust": 7.2, + "windSpeed": 3.27 + }, + { + "forecastStart": "2023-09-17T14:00:00Z", + "cloudCover": 0.44, + "conditionCode": "Drizzle", + "daylight": false, + "humidity": 0.9, + "precipitationAmount": 0.3, + "precipitationIntensity": 0.3, + "precipitationChance": 0.3, + "precipitationType": "rain", + "pressure": 1009.87, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.6, + "temperatureApparent": 25.87, + "temperatureDewPoint": 21.79, + "uvIndex": 0, + "visibility": 10700.0, + "windDirection": 182, + "windGust": 8.37, + "windSpeed": 3.22 + }, + { + "forecastStart": "2023-09-17T15:00:00Z", + "cloudCover": 0.49, + "conditionCode": "Drizzle", + "daylight": false, + "humidity": 0.92, + "precipitationAmount": 0.2, + "precipitationIntensity": 0.2, + "precipitationChance": 0.31, + "precipitationType": "rain", + "pressure": 1009.56, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.21, + "temperatureApparent": 25.46, + "temperatureDewPoint": 21.84, + "uvIndex": 0, + "visibility": 11364.0, + "windDirection": 180, + "windGust": 9.21, + "windSpeed": 3.3 + }, + { + "forecastStart": "2023-09-17T16:00:00Z", + "cloudCover": 0.53, + "conditionCode": "Drizzle", + "daylight": false, + "humidity": 0.94, + "precipitationAmount": 0.2, + "precipitationIntensity": 0.2, + "precipitationChance": 0.33, + "precipitationType": "rain", + "pressure": 1009.29, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 22.87, + "temperatureApparent": 25.08, + "temperatureDewPoint": 21.78, + "uvIndex": 0, + "visibility": 12623.0, + "windDirection": 182, + "windGust": 9.0, + "windSpeed": 3.46 + }, + { + "forecastStart": "2023-09-17T17:00:00Z", + "cloudCover": 0.56, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.95, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.35, + "precipitationType": "clear", + "pressure": 1009.09, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 22.62, + "temperatureApparent": 24.79, + "temperatureDewPoint": 21.74, + "uvIndex": 0, + "visibility": 14042.0, + "windDirection": 186, + "windGust": 8.37, + "windSpeed": 3.72 + }, + { + "forecastStart": "2023-09-17T18:00:00Z", + "cloudCover": 0.59, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.95, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.37, + "precipitationType": "clear", + "pressure": 1009.01, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 22.47, + "temperatureApparent": 24.57, + "temperatureDewPoint": 21.59, + "uvIndex": 0, + "visibility": 14809.0, + "windDirection": 201, + "windGust": 7.99, + "windSpeed": 4.07 + }, + { + "forecastStart": "2023-09-17T19:00:00Z", + "cloudCover": 0.62, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.94, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.39, + "precipitationType": "clear", + "pressure": 1009.07, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 22.68, + "temperatureApparent": 24.85, + "temperatureDewPoint": 21.73, + "uvIndex": 0, + "visibility": 14586.0, + "windDirection": 258, + "windGust": 8.18, + "windSpeed": 4.55 + }, + { + "forecastStart": "2023-09-17T20:00:00Z", + "cloudCover": 0.64, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.92, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.39, + "precipitationType": "clear", + "pressure": 1009.23, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.01, + "temperatureApparent": 25.2, + "temperatureDewPoint": 21.71, + "uvIndex": 0, + "visibility": 13831.0, + "windDirection": 305, + "windGust": 8.77, + "windSpeed": 5.17 + }, + { + "forecastStart": "2023-09-17T21:00:00Z", + "cloudCover": 0.68, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.9, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.38, + "precipitationType": "clear", + "pressure": 1009.47, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.51, + "temperatureApparent": 25.77, + "temperatureDewPoint": 21.77, + "uvIndex": 0, + "visibility": 12945.0, + "windDirection": 318, + "windGust": 9.69, + "windSpeed": 5.77 + }, + { + "forecastStart": "2023-09-17T22:00:00Z", + "cloudCover": 0.74, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.86, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.3, + "precipitationType": "clear", + "pressure": 1009.77, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.21, + "temperatureApparent": 26.53, + "temperatureDewPoint": 21.79, + "uvIndex": 1, + "visibility": 12093.0, + "windDirection": 324, + "windGust": 10.88, + "windSpeed": 6.26 + }, + { + "forecastStart": "2023-09-17T23:00:00Z", + "cloudCover": 0.8, + "conditionCode": "Drizzle", + "daylight": true, + "humidity": 0.83, + "precipitationAmount": 0.2, + "precipitationIntensity": 0.2, + "precipitationChance": 0.15, + "precipitationType": "rain", + "pressure": 1010.09, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 25.08, + "temperatureApparent": 27.55, + "temperatureDewPoint": 21.95, + "uvIndex": 2, + "visibility": 11231.0, + "windDirection": 329, + "windGust": 12.21, + "windSpeed": 6.68 + }, + { + "forecastStart": "2023-09-18T00:00:00Z", + "cloudCover": 0.87, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.8, + "precipitationAmount": 0.2, + "precipitationIntensity": 0.2, + "precipitationChance": 0.15, + "precipitationType": "rain", + "pressure": 1010.33, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 25.71, + "temperatureApparent": 28.22, + "temperatureDewPoint": 21.92, + "uvIndex": 3, + "visibility": 10426.0, + "windDirection": 332, + "windGust": 13.52, + "windSpeed": 7.12 + }, + { + "forecastStart": "2023-09-18T01:00:00Z", + "cloudCover": 0.67, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.72, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1007.43, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 27.19, + "temperatureApparent": 29.75, + "temperatureDewPoint": 21.7, + "uvIndex": 5, + "visibility": 24135.0, + "windDirection": 330, + "windGust": 11.36, + "windSpeed": 11.36 + }, + { + "forecastStart": "2023-09-18T02:00:00Z", + "cloudCover": 0.7, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.7, + "precipitationAmount": 0.3, + "precipitationIntensity": 0.3, + "precipitationChance": 0.09, + "precipitationType": "rain", + "pressure": 1007.05, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 27.51, + "temperatureApparent": 30.07, + "temperatureDewPoint": 21.64, + "uvIndex": 6, + "visibility": 24135.0, + "windDirection": 332, + "windGust": 12.06, + "windSpeed": 12.06 + }, + { + "forecastStart": "2023-09-18T03:00:00Z", + "cloudCover": 0.71, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.69, + "precipitationAmount": 0.5, + "precipitationIntensity": 0.5, + "precipitationChance": 0.1, + "precipitationType": "rain", + "pressure": 1006.67, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 27.75, + "temperatureApparent": 30.31, + "temperatureDewPoint": 21.59, + "uvIndex": 6, + "visibility": 24135.0, + "windDirection": 333, + "windGust": 12.81, + "windSpeed": 12.81 + }, + { + "forecastStart": "2023-09-18T04:00:00Z", + "cloudCover": 0.67, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.68, + "precipitationAmount": 0.4, + "precipitationIntensity": 0.4, + "precipitationChance": 0.1, + "precipitationType": "rain", + "pressure": 1006.28, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 27.99, + "temperatureApparent": 30.55, + "temperatureDewPoint": 21.53, + "uvIndex": 5, + "visibility": 24135.0, + "windDirection": 335, + "windGust": 13.68, + "windSpeed": 13.68 + }, + { + "forecastStart": "2023-09-18T05:00:00Z", + "cloudCover": 0.6, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.67, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1005.89, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 28.15, + "temperatureApparent": 30.66, + "temperatureDewPoint": 21.4, + "uvIndex": 4, + "visibility": 24135.0, + "windDirection": 336, + "windGust": 14.61, + "windSpeed": 14.61 + }, + { + "forecastStart": "2023-09-18T06:00:00Z", + "cloudCover": 0.57, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.67, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.27, + "precipitationType": "clear", + "pressure": 1005.67, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 27.92, + "temperatureApparent": 30.31, + "temperatureDewPoint": 21.18, + "uvIndex": 3, + "visibility": 24135.0, + "windDirection": 338, + "windGust": 15.25, + "windSpeed": 15.25 + }, + { + "forecastStart": "2023-09-18T07:00:00Z", + "cloudCover": 0.6, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.69, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.28, + "precipitationType": "clear", + "pressure": 1005.74, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 27.4, + "temperatureApparent": 29.78, + "temperatureDewPoint": 21.26, + "uvIndex": 1, + "visibility": 24135.0, + "windDirection": 339, + "windGust": 15.45, + "windSpeed": 15.45 + }, + { + "forecastStart": "2023-09-18T08:00:00Z", + "cloudCover": 0.65, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.73, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.26, + "precipitationType": "clear", + "pressure": 1005.98, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 26.73, + "temperatureApparent": 29.13, + "temperatureDewPoint": 21.44, + "uvIndex": 0, + "visibility": 24135.0, + "windDirection": 341, + "windGust": 15.38, + "windSpeed": 15.38 + }, + { + "forecastStart": "2023-09-18T09:00:00Z", + "cloudCover": 0.68, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.76, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1006.22, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 26.12, + "temperatureApparent": 28.55, + "temperatureDewPoint": 21.64, + "uvIndex": 0, + "visibility": 24135.0, + "windDirection": 341, + "windGust": 15.27, + "windSpeed": 15.27 + }, + { + "forecastStart": "2023-09-18T10:00:00Z", + "cloudCover": 0.66, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.79, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1006.44, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 25.56, + "temperatureApparent": 27.93, + "temperatureDewPoint": 21.61, + "uvIndex": 0, + "visibility": 24135.0, + "windDirection": 339, + "windGust": 15.09, + "windSpeed": 15.09 + }, + { + "forecastStart": "2023-09-18T11:00:00Z", + "cloudCover": 0.61, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.81, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.26, + "precipitationType": "clear", + "pressure": 1006.66, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 25.19, + "temperatureApparent": 27.58, + "temperatureDewPoint": 21.74, + "uvIndex": 0, + "visibility": 24135.0, + "windDirection": 336, + "windGust": 14.88, + "windSpeed": 14.88 + }, + { + "forecastStart": "2023-09-18T12:00:00Z", + "cloudCover": 0.61, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.83, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.26, + "precipitationType": "clear", + "pressure": 1006.79, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.83, + "temperatureApparent": 27.2, + "temperatureDewPoint": 21.78, + "uvIndex": 0, + "visibility": 24135.0, + "windDirection": 333, + "windGust": 14.91, + "windSpeed": 14.91 + }, + { + "forecastStart": "2023-09-18T13:00:00Z", + "cloudCover": 0.38, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.86, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.36, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.63, + "temperatureApparent": 25.69, + "temperatureDewPoint": 21.23, + "uvIndex": 0, + "visibility": 24135.0, + "windDirection": 83, + "windGust": 4.58, + "windSpeed": 3.16 + }, + { + "forecastStart": "2023-09-18T14:00:00Z", + "cloudCover": 0.74, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.89, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.96, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.13, + "temperatureApparent": 25.13, + "temperatureDewPoint": 21.18, + "uvIndex": 0, + "visibility": 24135.0, + "windDirection": 144, + "windGust": 4.74, + "windSpeed": 4.52 + }, + { + "forecastStart": "2023-09-18T15:00:00Z", + "cloudCover": 1.0, + "conditionCode": "Cloudy", + "daylight": false, + "humidity": 0.9, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.6, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 22.6, + "temperatureApparent": 24.48, + "temperatureDewPoint": 20.95, + "uvIndex": 0, + "visibility": 24135.0, + "windDirection": 152, + "windGust": 5.63, + "windSpeed": 5.63 + }, + { + "forecastStart": "2023-09-18T16:00:00Z", + "cloudCover": 1.0, + "conditionCode": "Cloudy", + "daylight": false, + "humidity": 0.91, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.37, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 22.27, + "temperatureApparent": 24.04, + "temperatureDewPoint": 20.69, + "uvIndex": 0, + "visibility": 24135.0, + "windDirection": 156, + "windGust": 6.02, + "windSpeed": 6.02 + }, + { + "forecastStart": "2023-09-18T17:00:00Z", + "cloudCover": 1.0, + "conditionCode": "Cloudy", + "daylight": false, + "humidity": 0.91, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.2, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 22.02, + "temperatureApparent": 23.69, + "temperatureDewPoint": 20.45, + "uvIndex": 0, + "visibility": 24135.0, + "windDirection": 162, + "windGust": 6.15, + "windSpeed": 6.15 + }, + { + "forecastStart": "2023-09-18T18:00:00Z", + "cloudCover": 1.0, + "conditionCode": "Cloudy", + "daylight": false, + "humidity": 0.9, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.08, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 21.88, + "temperatureApparent": 23.45, + "temperatureDewPoint": 20.16, + "uvIndex": 0, + "visibility": 24135.0, + "windDirection": 167, + "windGust": 6.48, + "windSpeed": 6.48 + }, + { + "forecastStart": "2023-09-18T19:00:00Z", + "cloudCover": 1.0, + "conditionCode": "Cloudy", + "daylight": false, + "humidity": 0.88, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.04, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 21.76, + "temperatureApparent": 23.19, + "temperatureDewPoint": 19.76, + "uvIndex": 0, + "visibility": 24135.0, + "windDirection": 165, + "windGust": 7.51, + "windSpeed": 7.51 + }, + { + "forecastStart": "2023-09-18T20:00:00Z", + "cloudCover": 0.99, + "conditionCode": "Cloudy", + "daylight": false, + "humidity": 0.86, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.05, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 21.96, + "temperatureApparent": 23.35, + "temperatureDewPoint": 19.58, + "uvIndex": 0, + "visibility": 24135.0, + "windDirection": 162, + "windGust": 8.73, + "windSpeed": 8.73 + }, + { + "forecastStart": "2023-09-18T21:00:00Z", + "cloudCover": 0.98, + "conditionCode": "Cloudy", + "daylight": true, + "humidity": 0.83, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.06, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 22.53, + "temperatureApparent": 23.93, + "temperatureDewPoint": 19.54, + "uvIndex": 0, + "visibility": 24135.0, + "windDirection": 164, + "windGust": 9.21, + "windSpeed": 9.11 + }, + { + "forecastStart": "2023-09-18T22:00:00Z", + "cloudCover": 0.96, + "conditionCode": "Cloudy", + "daylight": true, + "humidity": 0.78, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.09, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.8, + "temperatureApparent": 25.34, + "temperatureDewPoint": 19.73, + "uvIndex": 1, + "visibility": 24204.0, + "windDirection": 171, + "windGust": 9.03, + "windSpeed": 7.91 + } + ] + } +} diff --git a/tests/components/weatherkit/snapshots/test_weather.ambr b/tests/components/weatherkit/snapshots/test_weather.ambr new file mode 100644 index 00000000000000..63321b5a81321f --- /dev/null +++ b/tests/components/weatherkit/snapshots/test_weather.ambr @@ -0,0 +1,4087 @@ +# serializer version: 1 +# name: test_daily_forecast + dict({ + 'forecast': list([ + dict({ + 'condition': 'cloudy', + 'datetime': '2023-09-08T15:00:00Z', + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'temperature': 28.6, + 'templow': 21.2, + 'uv_index': 6, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2023-09-09T15:00:00Z', + 'precipitation': 3.6, + 'precipitation_probability': 45.0, + 'temperature': 30.6, + 'templow': 21.0, + 'uv_index': 6, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2023-09-10T15:00:00Z', + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'temperature': 30.4, + 'templow': 23.1, + 'uv_index': 6, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2023-09-11T15:00:00Z', + 'precipitation': 0.7, + 'precipitation_probability': 47.0, + 'temperature': 30.4, + 'templow': 23.1, + 'uv_index': 5, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2023-09-12T15:00:00Z', + 'precipitation': 7.7, + 'precipitation_probability': 37.0, + 'temperature': 30.4, + 'templow': 22.1, + 'uv_index': 6, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2023-09-13T15:00:00Z', + 'precipitation': 0.6, + 'precipitation_probability': 45.0, + 'temperature': 31.0, + 'templow': 22.6, + 'uv_index': 6, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T15:00:00Z', + 'precipitation': 0.0, + 'precipitation_probability': 52.0, + 'temperature': 31.5, + 'templow': 22.4, + 'uv_index': 7, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2023-09-15T15:00:00Z', + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'temperature': 31.8, + 'templow': 23.3, + 'uv_index': 8, + }), + dict({ + 'condition': 'lightning', + 'datetime': '2023-09-16T15:00:00Z', + 'precipitation': 5.3, + 'precipitation_probability': 35.0, + 'temperature': 30.7, + 'templow': 23.2, + 'uv_index': 8, + }), + dict({ + 'condition': 'lightning', + 'datetime': '2023-09-17T15:00:00Z', + 'precipitation': 2.1, + 'precipitation_probability': 49.0, + 'temperature': 28.1, + 'templow': 22.5, + 'uv_index': 6, + }), + ]), + }) +# --- +# name: test_hourly_forecast + dict({ + 'forecast': list([ + dict({ + 'apparent_temperature': 24.6, + 'cloud_coverage': 79.0, + 'condition': 'cloudy', + 'datetime': '2023-09-08T14:00:00Z', + 'dew_point': 21.5, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.24, + 'temperature': 22.6, + 'uv_index': 0, + 'wind_bearing': 264, + 'wind_gust_speed': 13.44, + 'wind_speed': 6.62, + }), + dict({ + 'apparent_temperature': 24.4, + 'cloud_coverage': 80.0, + 'condition': 'cloudy', + 'datetime': '2023-09-08T15:00:00Z', + 'dew_point': 21.4, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.24, + 'temperature': 22.4, + 'uv_index': 0, + 'wind_bearing': 261, + 'wind_gust_speed': 11.91, + 'wind_speed': 6.64, + }), + dict({ + 'apparent_temperature': 23.8, + 'cloud_coverage': 89.0, + 'condition': 'cloudy', + 'datetime': '2023-09-08T16:00:00Z', + 'dew_point': 21.1, + 'humidity': 95, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.12, + 'temperature': 22.0, + 'uv_index': 0, + 'wind_bearing': 252, + 'wind_gust_speed': 11.15, + 'wind_speed': 6.14, + }), + dict({ + 'apparent_temperature': 23.5, + 'cloud_coverage': 86.0, + 'condition': 'cloudy', + 'datetime': '2023-09-08T17:00:00Z', + 'dew_point': 20.9, + 'humidity': 95, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.03, + 'temperature': 21.7, + 'uv_index': 0, + 'wind_bearing': 248, + 'wind_gust_speed': 11.57, + 'wind_speed': 5.95, + }), + dict({ + 'apparent_temperature': 23.3, + 'cloud_coverage': 85.0, + 'condition': 'cloudy', + 'datetime': '2023-09-08T18:00:00Z', + 'dew_point': 20.8, + 'humidity': 95, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.05, + 'temperature': 21.6, + 'uv_index': 0, + 'wind_bearing': 237, + 'wind_gust_speed': 12.42, + 'wind_speed': 5.86, + }), + dict({ + 'apparent_temperature': 23.0, + 'cloud_coverage': 75.0, + 'condition': 'cloudy', + 'datetime': '2023-09-08T19:00:00Z', + 'dew_point': 20.6, + 'humidity': 96, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.03, + 'temperature': 21.3, + 'uv_index': 0, + 'wind_bearing': 224, + 'wind_gust_speed': 11.3, + 'wind_speed': 5.34, + }), + dict({ + 'apparent_temperature': 22.8, + 'cloud_coverage': 68.0, + 'condition': 'cloudy', + 'datetime': '2023-09-08T20:00:00Z', + 'dew_point': 20.4, + 'humidity': 96, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.31, + 'temperature': 21.2, + 'uv_index': 0, + 'wind_bearing': 221, + 'wind_gust_speed': 10.57, + 'wind_speed': 5.13, + }), + dict({ + 'apparent_temperature': 23.1, + 'cloud_coverage': 56.99999999999999, + 'condition': 'partlycloudy', + 'datetime': '2023-09-08T21:00:00Z', + 'dew_point': 20.5, + 'humidity': 95, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.55, + 'temperature': 21.4, + 'uv_index': 0, + 'wind_bearing': 237, + 'wind_gust_speed': 10.63, + 'wind_speed': 5.7, + }), + dict({ + 'apparent_temperature': 24.9, + 'cloud_coverage': 61.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-08T22:00:00Z', + 'dew_point': 21.3, + 'humidity': 91, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.79, + 'temperature': 22.8, + 'uv_index': 1, + 'wind_bearing': 258, + 'wind_gust_speed': 10.47, + 'wind_speed': 5.22, + }), + dict({ + 'apparent_temperature': 26.1, + 'cloud_coverage': 74.0, + 'condition': 'cloudy', + 'datetime': '2023-09-08T23:00:00Z', + 'dew_point': 21.3, + 'humidity': 85, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.95, + 'temperature': 24.0, + 'uv_index': 2, + 'wind_bearing': 282, + 'wind_gust_speed': 12.74, + 'wind_speed': 5.71, + }), + dict({ + 'apparent_temperature': 27.4, + 'cloud_coverage': 84.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T00:00:00Z', + 'dew_point': 21.5, + 'humidity': 80, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.35, + 'temperature': 25.1, + 'uv_index': 3, + 'wind_bearing': 294, + 'wind_gust_speed': 13.87, + 'wind_speed': 6.53, + }), + dict({ + 'apparent_temperature': 29.0, + 'cloud_coverage': 72.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T01:00:00Z', + 'dew_point': 21.8, + 'humidity': 75, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.48, + 'temperature': 26.5, + 'uv_index': 5, + 'wind_bearing': 308, + 'wind_gust_speed': 16.04, + 'wind_speed': 6.54, + }), + dict({ + 'apparent_temperature': 30.3, + 'cloud_coverage': 76.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T02:00:00Z', + 'dew_point': 22.0, + 'humidity': 72, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.23, + 'temperature': 27.6, + 'uv_index': 6, + 'wind_bearing': 314, + 'wind_gust_speed': 18.1, + 'wind_speed': 7.32, + }), + dict({ + 'apparent_temperature': 31.1, + 'cloud_coverage': 70.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T03:00:00Z', + 'dew_point': 22.1, + 'humidity': 69, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.86, + 'temperature': 28.3, + 'uv_index': 6, + 'wind_bearing': 317, + 'wind_gust_speed': 20.77, + 'wind_speed': 9.1, + }), + dict({ + 'apparent_temperature': 31.5, + 'cloud_coverage': 69.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T04:00:00Z', + 'dew_point': 22.1, + 'humidity': 68, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.65, + 'temperature': 28.6, + 'uv_index': 6, + 'wind_bearing': 311, + 'wind_gust_speed': 21.27, + 'wind_speed': 10.21, + }), + dict({ + 'apparent_temperature': 31.3, + 'cloud_coverage': 71.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T05:00:00Z', + 'dew_point': 22.1, + 'humidity': 69, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.48, + 'temperature': 28.4, + 'uv_index': 5, + 'wind_bearing': 317, + 'wind_gust_speed': 19.62, + 'wind_speed': 10.53, + }), + dict({ + 'apparent_temperature': 30.8, + 'cloud_coverage': 86.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T06:00:00Z', + 'dew_point': 22.2, + 'humidity': 71, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.54, + 'temperature': 27.9, + 'uv_index': 3, + 'wind_bearing': 335, + 'wind_gust_speed': 18.98, + 'wind_speed': 8.63, + }), + dict({ + 'apparent_temperature': 29.9, + 'cloud_coverage': 84.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T07:00:00Z', + 'dew_point': 22.2, + 'humidity': 74, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.76, + 'temperature': 27.1, + 'uv_index': 2, + 'wind_bearing': 338, + 'wind_gust_speed': 17.04, + 'wind_speed': 7.75, + }), + dict({ + 'apparent_temperature': 29.1, + 'cloud_coverage': 72.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T08:00:00Z', + 'dew_point': 22.1, + 'humidity': 78, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.05, + 'temperature': 26.4, + 'uv_index': 0, + 'wind_bearing': 342, + 'wind_gust_speed': 14.75, + 'wind_speed': 6.26, + }), + dict({ + 'apparent_temperature': 27.9, + 'cloud_coverage': 72.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T09:00:00Z', + 'dew_point': 22.0, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.38, + 'temperature': 25.4, + 'uv_index': 0, + 'wind_bearing': 344, + 'wind_gust_speed': 10.43, + 'wind_speed': 5.2, + }), + dict({ + 'apparent_temperature': 26.9, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T10:00:00Z', + 'dew_point': 21.9, + 'humidity': 85, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.73, + 'temperature': 24.5, + 'uv_index': 0, + 'wind_bearing': 339, + 'wind_gust_speed': 6.95, + 'wind_speed': 3.59, + }), + dict({ + 'apparent_temperature': 26.4, + 'cloud_coverage': 51.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-09T11:00:00Z', + 'dew_point': 21.8, + 'humidity': 87, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.3, + 'temperature': 24.1, + 'uv_index': 0, + 'wind_bearing': 326, + 'wind_gust_speed': 5.27, + 'wind_speed': 2.1, + }), + dict({ + 'apparent_temperature': 26.1, + 'cloud_coverage': 53.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-09T12:00:00Z', + 'dew_point': 21.8, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.52, + 'temperature': 23.9, + 'uv_index': 0, + 'wind_bearing': 257, + 'wind_gust_speed': 5.48, + 'wind_speed': 0.93, + }), + dict({ + 'apparent_temperature': 25.8, + 'cloud_coverage': 56.99999999999999, + 'condition': 'partlycloudy', + 'datetime': '2023-09-09T13:00:00Z', + 'dew_point': 21.8, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.53, + 'temperature': 23.5, + 'uv_index': 0, + 'wind_bearing': 188, + 'wind_gust_speed': 4.44, + 'wind_speed': 1.79, + }), + dict({ + 'apparent_temperature': 25.3, + 'cloud_coverage': 64.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T14:00:00Z', + 'dew_point': 21.7, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.46, + 'temperature': 23.1, + 'uv_index': 0, + 'wind_bearing': 183, + 'wind_gust_speed': 4.49, + 'wind_speed': 2.19, + }), + dict({ + 'apparent_temperature': 24.6, + 'cloud_coverage': 45.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-09T15:00:00Z', + 'dew_point': 21.4, + 'humidity': 93, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.21, + 'temperature': 22.6, + 'uv_index': 0, + 'wind_bearing': 179, + 'wind_gust_speed': 5.32, + 'wind_speed': 2.65, + }), + dict({ + 'apparent_temperature': 24.0, + 'cloud_coverage': 42.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-09T16:00:00Z', + 'dew_point': 21.1, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.09, + 'temperature': 22.1, + 'uv_index': 0, + 'wind_bearing': 173, + 'wind_gust_speed': 5.81, + 'wind_speed': 3.2, + }), + dict({ + 'apparent_temperature': 23.7, + 'cloud_coverage': 54.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-09T17:00:00Z', + 'dew_point': 20.9, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.88, + 'temperature': 21.9, + 'uv_index': 0, + 'wind_bearing': 159, + 'wind_gust_speed': 5.53, + 'wind_speed': 3.16, + }), + dict({ + 'apparent_temperature': 23.3, + 'cloud_coverage': 54.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-09T18:00:00Z', + 'dew_point': 20.7, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.94, + 'temperature': 21.6, + 'uv_index': 0, + 'wind_bearing': 153, + 'wind_gust_speed': 6.09, + 'wind_speed': 3.36, + }), + dict({ + 'apparent_temperature': 23.1, + 'cloud_coverage': 51.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-09T19:00:00Z', + 'dew_point': 20.5, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.96, + 'temperature': 21.4, + 'uv_index': 0, + 'wind_bearing': 150, + 'wind_gust_speed': 6.83, + 'wind_speed': 3.71, + }), + dict({ + 'apparent_temperature': 22.5, + 'cloud_coverage': 70.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T20:00:00Z', + 'dew_point': 20.0, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.29, + 'temperature': 21.0, + 'uv_index': 0, + 'wind_bearing': 156, + 'wind_gust_speed': 7.98, + 'wind_speed': 4.27, + }), + dict({ + 'apparent_temperature': 22.8, + 'cloud_coverage': 76.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T21:00:00Z', + 'dew_point': 20.2, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.61, + 'temperature': 21.2, + 'uv_index': 0, + 'wind_bearing': 156, + 'wind_gust_speed': 8.4, + 'wind_speed': 4.69, + }), + dict({ + 'apparent_temperature': 25.1, + 'cloud_coverage': 68.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T22:00:00Z', + 'dew_point': 21.3, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.87, + 'temperature': 23.1, + 'uv_index': 1, + 'wind_bearing': 150, + 'wind_gust_speed': 7.66, + 'wind_speed': 4.33, + }), + dict({ + 'apparent_temperature': 28.3, + 'cloud_coverage': 57.99999999999999, + 'condition': 'partlycloudy', + 'datetime': '2023-09-09T23:00:00Z', + 'dew_point': 22.3, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.93, + 'temperature': 25.6, + 'uv_index': 2, + 'wind_bearing': 123, + 'wind_gust_speed': 9.63, + 'wind_speed': 3.91, + }), + dict({ + 'apparent_temperature': 30.4, + 'cloud_coverage': 63.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T00:00:00Z', + 'dew_point': 22.6, + 'humidity': 75, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.93, + 'temperature': 27.4, + 'uv_index': 4, + 'wind_bearing': 105, + 'wind_gust_speed': 12.59, + 'wind_speed': 3.96, + }), + dict({ + 'apparent_temperature': 32.2, + 'cloud_coverage': 66.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T01:00:00Z', + 'dew_point': 22.9, + 'humidity': 70, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.79, + 'temperature': 28.9, + 'uv_index': 5, + 'wind_bearing': 99, + 'wind_gust_speed': 14.17, + 'wind_speed': 4.06, + }), + dict({ + 'apparent_temperature': 33.4, + 'cloud_coverage': 62.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-10T02:00:00Z', + 'dew_point': 22.9, + 'humidity': 66, + 'precipitation': 0.3, + 'precipitation_probability': 7.000000000000001, + 'pressure': 1011.29, + 'temperature': 29.9, + 'uv_index': 6, + 'wind_bearing': 93, + 'wind_gust_speed': 17.75, + 'wind_speed': 4.87, + }), + dict({ + 'apparent_temperature': 34.3, + 'cloud_coverage': 74.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T03:00:00Z', + 'dew_point': 23.1, + 'humidity': 64, + 'precipitation': 0.3, + 'precipitation_probability': 11.0, + 'pressure': 1010.78, + 'temperature': 30.6, + 'uv_index': 6, + 'wind_bearing': 78, + 'wind_gust_speed': 17.43, + 'wind_speed': 4.54, + }), + dict({ + 'apparent_temperature': 34.0, + 'cloud_coverage': 74.0, + 'condition': 'rainy', + 'datetime': '2023-09-10T04:00:00Z', + 'dew_point': 23.2, + 'humidity': 66, + 'precipitation': 0.4, + 'precipitation_probability': 15.0, + 'pressure': 1010.37, + 'temperature': 30.3, + 'uv_index': 5, + 'wind_bearing': 60, + 'wind_gust_speed': 15.24, + 'wind_speed': 4.9, + }), + dict({ + 'apparent_temperature': 33.7, + 'cloud_coverage': 79.0, + 'condition': 'rainy', + 'datetime': '2023-09-10T05:00:00Z', + 'dew_point': 23.3, + 'humidity': 67, + 'precipitation': 0.7, + 'precipitation_probability': 17.0, + 'pressure': 1010.09, + 'temperature': 30.0, + 'uv_index': 4, + 'wind_bearing': 80, + 'wind_gust_speed': 13.53, + 'wind_speed': 5.98, + }), + dict({ + 'apparent_temperature': 33.2, + 'cloud_coverage': 80.0, + 'condition': 'rainy', + 'datetime': '2023-09-10T06:00:00Z', + 'dew_point': 23.4, + 'humidity': 70, + 'precipitation': 1.0, + 'precipitation_probability': 17.0, + 'pressure': 1010.0, + 'temperature': 29.5, + 'uv_index': 3, + 'wind_bearing': 83, + 'wind_gust_speed': 12.55, + 'wind_speed': 6.84, + }), + dict({ + 'apparent_temperature': 32.3, + 'cloud_coverage': 88.0, + 'condition': 'rainy', + 'datetime': '2023-09-10T07:00:00Z', + 'dew_point': 23.4, + 'humidity': 73, + 'precipitation': 0.4, + 'precipitation_probability': 16.0, + 'pressure': 1010.27, + 'temperature': 28.7, + 'uv_index': 2, + 'wind_bearing': 90, + 'wind_gust_speed': 10.16, + 'wind_speed': 6.07, + }), + dict({ + 'apparent_temperature': 30.9, + 'cloud_coverage': 92.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T08:00:00Z', + 'dew_point': 23.2, + 'humidity': 77, + 'precipitation': 0.5, + 'precipitation_probability': 14.000000000000002, + 'pressure': 1010.71, + 'temperature': 27.6, + 'uv_index': 0, + 'wind_bearing': 101, + 'wind_gust_speed': 8.18, + 'wind_speed': 4.82, + }), + dict({ + 'apparent_temperature': 29.7, + 'cloud_coverage': 93.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T09:00:00Z', + 'dew_point': 23.2, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.9, + 'temperature': 26.5, + 'uv_index': 0, + 'wind_bearing': 128, + 'wind_gust_speed': 8.89, + 'wind_speed': 4.95, + }), + dict({ + 'apparent_temperature': 28.6, + 'cloud_coverage': 88.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T10:00:00Z', + 'dew_point': 23.0, + 'humidity': 86, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.12, + 'temperature': 25.6, + 'uv_index': 0, + 'wind_bearing': 134, + 'wind_gust_speed': 10.03, + 'wind_speed': 4.52, + }), + dict({ + 'apparent_temperature': 27.9, + 'cloud_coverage': 87.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T11:00:00Z', + 'dew_point': 22.8, + 'humidity': 87, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.43, + 'temperature': 25.1, + 'uv_index': 0, + 'wind_bearing': 137, + 'wind_gust_speed': 12.4, + 'wind_speed': 5.41, + }), + dict({ + 'apparent_temperature': 27.4, + 'cloud_coverage': 82.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T12:00:00Z', + 'dew_point': 22.5, + 'humidity': 87, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.58, + 'temperature': 24.8, + 'uv_index': 0, + 'wind_bearing': 143, + 'wind_gust_speed': 16.36, + 'wind_speed': 6.31, + }), + dict({ + 'apparent_temperature': 27.1, + 'cloud_coverage': 82.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T13:00:00Z', + 'dew_point': 22.4, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.55, + 'temperature': 24.5, + 'uv_index': 0, + 'wind_bearing': 144, + 'wind_gust_speed': 19.66, + 'wind_speed': 7.23, + }), + dict({ + 'apparent_temperature': 26.8, + 'cloud_coverage': 72.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T14:00:00Z', + 'dew_point': 22.2, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.4, + 'temperature': 24.3, + 'uv_index': 0, + 'wind_bearing': 141, + 'wind_gust_speed': 21.15, + 'wind_speed': 7.46, + }), + dict({ + 'apparent_temperature': 26.3, + 'cloud_coverage': 74.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T15:00:00Z', + 'dew_point': 22.0, + 'humidity': 89, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.23, + 'temperature': 23.9, + 'uv_index': 0, + 'wind_bearing': 141, + 'wind_gust_speed': 22.26, + 'wind_speed': 7.84, + }), + dict({ + 'apparent_temperature': 26.1, + 'cloud_coverage': 70.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T16:00:00Z', + 'dew_point': 21.8, + 'humidity': 89, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.01, + 'temperature': 23.8, + 'uv_index': 0, + 'wind_bearing': 144, + 'wind_gust_speed': 23.53, + 'wind_speed': 8.63, + }), + dict({ + 'apparent_temperature': 25.6, + 'cloud_coverage': 61.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-10T17:00:00Z', + 'dew_point': 21.6, + 'humidity': 89, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.78, + 'temperature': 23.5, + 'uv_index': 0, + 'wind_bearing': 144, + 'wind_gust_speed': 22.83, + 'wind_speed': 8.61, + }), + dict({ + 'apparent_temperature': 25.4, + 'cloud_coverage': 74.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T18:00:00Z', + 'dew_point': 21.5, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.69, + 'temperature': 23.3, + 'uv_index': 0, + 'wind_bearing': 143, + 'wind_gust_speed': 23.7, + 'wind_speed': 8.7, + }), + dict({ + 'apparent_temperature': 25.2, + 'cloud_coverage': 84.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T19:00:00Z', + 'dew_point': 21.4, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.77, + 'temperature': 23.1, + 'uv_index': 0, + 'wind_bearing': 140, + 'wind_gust_speed': 24.24, + 'wind_speed': 8.74, + }), + dict({ + 'apparent_temperature': 25.5, + 'cloud_coverage': 89.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T20:00:00Z', + 'dew_point': 21.6, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.89, + 'temperature': 23.3, + 'uv_index': 0, + 'wind_bearing': 138, + 'wind_gust_speed': 23.99, + 'wind_speed': 8.81, + }), + dict({ + 'apparent_temperature': 25.9, + 'cloud_coverage': 73.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T21:00:00Z', + 'dew_point': 21.6, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.1, + 'temperature': 23.7, + 'uv_index': 0, + 'wind_bearing': 138, + 'wind_gust_speed': 25.55, + 'wind_speed': 9.05, + }), + dict({ + 'apparent_temperature': 27.0, + 'cloud_coverage': 71.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T22:00:00Z', + 'dew_point': 21.8, + 'humidity': 84, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.29, + 'temperature': 24.6, + 'uv_index': 1, + 'wind_bearing': 140, + 'wind_gust_speed': 29.08, + 'wind_speed': 10.37, + }), + dict({ + 'apparent_temperature': 28.4, + 'cloud_coverage': 70.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T23:00:00Z', + 'dew_point': 21.9, + 'humidity': 79, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.36, + 'temperature': 25.9, + 'uv_index': 2, + 'wind_bearing': 140, + 'wind_gust_speed': 34.13, + 'wind_speed': 12.56, + }), + dict({ + 'apparent_temperature': 30.1, + 'cloud_coverage': 68.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T00:00:00Z', + 'dew_point': 22.3, + 'humidity': 74, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.39, + 'temperature': 27.2, + 'uv_index': 3, + 'wind_bearing': 140, + 'wind_gust_speed': 38.2, + 'wind_speed': 15.65, + }), + dict({ + 'apparent_temperature': 31.4, + 'cloud_coverage': 57.99999999999999, + 'condition': 'partlycloudy', + 'datetime': '2023-09-11T01:00:00Z', + 'dew_point': 22.3, + 'humidity': 70, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.31, + 'temperature': 28.4, + 'uv_index': 5, + 'wind_bearing': 141, + 'wind_gust_speed': 37.55, + 'wind_speed': 15.78, + }), + dict({ + 'apparent_temperature': 32.7, + 'cloud_coverage': 63.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T02:00:00Z', + 'dew_point': 22.4, + 'humidity': 66, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.98, + 'temperature': 29.6, + 'uv_index': 6, + 'wind_bearing': 143, + 'wind_gust_speed': 35.86, + 'wind_speed': 15.41, + }), + dict({ + 'apparent_temperature': 33.5, + 'cloud_coverage': 64.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T03:00:00Z', + 'dew_point': 22.5, + 'humidity': 63, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.61, + 'temperature': 30.3, + 'uv_index': 6, + 'wind_bearing': 141, + 'wind_gust_speed': 35.88, + 'wind_speed': 15.51, + }), + dict({ + 'apparent_temperature': 33.8, + 'cloud_coverage': 74.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T04:00:00Z', + 'dew_point': 22.6, + 'humidity': 63, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.36, + 'temperature': 30.4, + 'uv_index': 5, + 'wind_bearing': 140, + 'wind_gust_speed': 35.99, + 'wind_speed': 15.75, + }), + dict({ + 'apparent_temperature': 33.5, + 'cloud_coverage': 76.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T05:00:00Z', + 'dew_point': 22.6, + 'humidity': 64, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.11, + 'temperature': 30.1, + 'uv_index': 4, + 'wind_bearing': 137, + 'wind_gust_speed': 33.61, + 'wind_speed': 15.36, + }), + dict({ + 'apparent_temperature': 33.2, + 'cloud_coverage': 77.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T06:00:00Z', + 'dew_point': 22.5, + 'humidity': 64, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.98, + 'temperature': 30.0, + 'uv_index': 3, + 'wind_bearing': 138, + 'wind_gust_speed': 32.61, + 'wind_speed': 14.98, + }), + dict({ + 'apparent_temperature': 32.3, + 'cloud_coverage': 64.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T07:00:00Z', + 'dew_point': 22.2, + 'humidity': 66, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.13, + 'temperature': 29.2, + 'uv_index': 2, + 'wind_bearing': 138, + 'wind_gust_speed': 28.1, + 'wind_speed': 13.88, + }), + dict({ + 'apparent_temperature': 31.2, + 'cloud_coverage': 56.00000000000001, + 'condition': 'partlycloudy', + 'datetime': '2023-09-11T08:00:00Z', + 'dew_point': 22.1, + 'humidity': 69, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.48, + 'temperature': 28.3, + 'uv_index': 0, + 'wind_bearing': 137, + 'wind_gust_speed': 24.22, + 'wind_speed': 13.02, + }), + dict({ + 'apparent_temperature': 29.8, + 'cloud_coverage': 55.00000000000001, + 'condition': 'partlycloudy', + 'datetime': '2023-09-11T09:00:00Z', + 'dew_point': 21.9, + 'humidity': 73, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.81, + 'temperature': 27.1, + 'uv_index': 0, + 'wind_bearing': 138, + 'wind_gust_speed': 22.5, + 'wind_speed': 11.94, + }), + dict({ + 'apparent_temperature': 28.8, + 'cloud_coverage': 63.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T10:00:00Z', + 'dew_point': 21.7, + 'humidity': 76, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.29, + 'temperature': 26.3, + 'uv_index': 0, + 'wind_bearing': 137, + 'wind_gust_speed': 21.47, + 'wind_speed': 11.25, + }), + dict({ + 'apparent_temperature': 28.1, + 'cloud_coverage': 86.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T11:00:00Z', + 'dew_point': 21.8, + 'humidity': 80, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.77, + 'temperature': 25.6, + 'uv_index': 0, + 'wind_bearing': 141, + 'wind_gust_speed': 22.71, + 'wind_speed': 12.39, + }), + dict({ + 'apparent_temperature': 27.6, + 'cloud_coverage': 86.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T12:00:00Z', + 'dew_point': 21.8, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.97, + 'temperature': 25.2, + 'uv_index': 0, + 'wind_bearing': 143, + 'wind_gust_speed': 23.67, + 'wind_speed': 12.83, + }), + dict({ + 'apparent_temperature': 27.1, + 'cloud_coverage': 89.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T13:00:00Z', + 'dew_point': 21.7, + 'humidity': 83, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.97, + 'temperature': 24.7, + 'uv_index': 0, + 'wind_bearing': 146, + 'wind_gust_speed': 23.34, + 'wind_speed': 12.62, + }), + dict({ + 'apparent_temperature': 26.7, + 'cloud_coverage': 88.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T14:00:00Z', + 'dew_point': 21.7, + 'humidity': 85, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.83, + 'temperature': 24.4, + 'uv_index': 0, + 'wind_bearing': 147, + 'wind_gust_speed': 22.9, + 'wind_speed': 12.07, + }), + dict({ + 'apparent_temperature': 26.3, + 'cloud_coverage': 90.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T15:00:00Z', + 'dew_point': 21.6, + 'humidity': 86, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.74, + 'temperature': 24.1, + 'uv_index': 0, + 'wind_bearing': 147, + 'wind_gust_speed': 22.01, + 'wind_speed': 11.19, + }), + dict({ + 'apparent_temperature': 25.9, + 'cloud_coverage': 88.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T16:00:00Z', + 'dew_point': 21.6, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.56, + 'temperature': 23.7, + 'uv_index': 0, + 'wind_bearing': 149, + 'wind_gust_speed': 21.29, + 'wind_speed': 10.97, + }), + dict({ + 'apparent_temperature': 25.8, + 'cloud_coverage': 85.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T17:00:00Z', + 'dew_point': 21.5, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.35, + 'temperature': 23.6, + 'uv_index': 0, + 'wind_bearing': 150, + 'wind_gust_speed': 20.52, + 'wind_speed': 10.5, + }), + dict({ + 'apparent_temperature': 25.7, + 'cloud_coverage': 82.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T18:00:00Z', + 'dew_point': 21.4, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.3, + 'temperature': 23.5, + 'uv_index': 0, + 'wind_bearing': 149, + 'wind_gust_speed': 20.04, + 'wind_speed': 10.51, + }), + dict({ + 'apparent_temperature': 25.4, + 'cloud_coverage': 78.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T19:00:00Z', + 'dew_point': 21.3, + 'humidity': 88, + 'precipitation': 0.3, + 'precipitation_probability': 12.0, + 'pressure': 1011.37, + 'temperature': 23.4, + 'uv_index': 0, + 'wind_bearing': 146, + 'wind_gust_speed': 18.07, + 'wind_speed': 10.13, + }), + dict({ + 'apparent_temperature': 25.2, + 'cloud_coverage': 78.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T20:00:00Z', + 'dew_point': 21.2, + 'humidity': 89, + 'precipitation': 0.2, + 'precipitation_probability': 13.0, + 'pressure': 1011.53, + 'temperature': 23.1, + 'uv_index': 0, + 'wind_bearing': 141, + 'wind_gust_speed': 16.86, + 'wind_speed': 10.34, + }), + dict({ + 'apparent_temperature': 25.5, + 'cloud_coverage': 78.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T21:00:00Z', + 'dew_point': 21.4, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.71, + 'temperature': 23.4, + 'uv_index': 0, + 'wind_bearing': 138, + 'wind_gust_speed': 16.66, + 'wind_speed': 10.68, + }), + dict({ + 'apparent_temperature': 26.8, + 'cloud_coverage': 78.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T22:00:00Z', + 'dew_point': 21.9, + 'humidity': 86, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.94, + 'temperature': 24.4, + 'uv_index': 1, + 'wind_bearing': 137, + 'wind_gust_speed': 17.21, + 'wind_speed': 10.61, + }), + dict({ + 'apparent_temperature': 28.2, + 'cloud_coverage': 78.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T23:00:00Z', + 'dew_point': 22.3, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.05, + 'temperature': 25.6, + 'uv_index': 2, + 'wind_bearing': 138, + 'wind_gust_speed': 19.23, + 'wind_speed': 11.13, + }), + dict({ + 'apparent_temperature': 29.5, + 'cloud_coverage': 79.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T00:00:00Z', + 'dew_point': 22.6, + 'humidity': 79, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.07, + 'temperature': 26.6, + 'uv_index': 3, + 'wind_bearing': 140, + 'wind_gust_speed': 20.61, + 'wind_speed': 11.13, + }), + dict({ + 'apparent_temperature': 31.2, + 'cloud_coverage': 82.0, + 'condition': 'rainy', + 'datetime': '2023-09-12T01:00:00Z', + 'dew_point': 23.1, + 'humidity': 75, + 'precipitation': 0.2, + 'precipitation_probability': 16.0, + 'pressure': 1011.89, + 'temperature': 27.9, + 'uv_index': 4, + 'wind_bearing': 141, + 'wind_gust_speed': 23.35, + 'wind_speed': 11.98, + }), + dict({ + 'apparent_temperature': 32.6, + 'cloud_coverage': 85.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T02:00:00Z', + 'dew_point': 23.5, + 'humidity': 72, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.53, + 'temperature': 29.0, + 'uv_index': 5, + 'wind_bearing': 143, + 'wind_gust_speed': 26.45, + 'wind_speed': 13.01, + }), + dict({ + 'apparent_temperature': 33.5, + 'cloud_coverage': 84.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T03:00:00Z', + 'dew_point': 23.5, + 'humidity': 69, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.15, + 'temperature': 29.8, + 'uv_index': 5, + 'wind_bearing': 141, + 'wind_gust_speed': 28.95, + 'wind_speed': 13.9, + }), + dict({ + 'apparent_temperature': 34.0, + 'cloud_coverage': 73.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T04:00:00Z', + 'dew_point': 23.4, + 'humidity': 67, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.79, + 'temperature': 30.2, + 'uv_index': 5, + 'wind_bearing': 141, + 'wind_gust_speed': 27.9, + 'wind_speed': 13.95, + }), + dict({ + 'apparent_temperature': 34.0, + 'cloud_coverage': 64.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T05:00:00Z', + 'dew_point': 23.1, + 'humidity': 65, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.43, + 'temperature': 30.4, + 'uv_index': 4, + 'wind_bearing': 140, + 'wind_gust_speed': 26.53, + 'wind_speed': 13.78, + }), + dict({ + 'apparent_temperature': 33.4, + 'cloud_coverage': 56.00000000000001, + 'condition': 'partlycloudy', + 'datetime': '2023-09-12T06:00:00Z', + 'dew_point': 22.6, + 'humidity': 64, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.21, + 'temperature': 30.1, + 'uv_index': 3, + 'wind_bearing': 138, + 'wind_gust_speed': 24.56, + 'wind_speed': 13.74, + }), + dict({ + 'apparent_temperature': 32.0, + 'cloud_coverage': 53.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-12T07:00:00Z', + 'dew_point': 22.1, + 'humidity': 66, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.26, + 'temperature': 29.1, + 'uv_index': 2, + 'wind_bearing': 138, + 'wind_gust_speed': 22.78, + 'wind_speed': 13.21, + }), + dict({ + 'apparent_temperature': 30.9, + 'cloud_coverage': 48.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-12T08:00:00Z', + 'dew_point': 21.9, + 'humidity': 69, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.51, + 'temperature': 28.1, + 'uv_index': 0, + 'wind_bearing': 140, + 'wind_gust_speed': 19.92, + 'wind_speed': 12.0, + }), + dict({ + 'apparent_temperature': 29.7, + 'cloud_coverage': 50.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-12T09:00:00Z', + 'dew_point': 21.7, + 'humidity': 72, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.8, + 'temperature': 27.2, + 'uv_index': 0, + 'wind_bearing': 141, + 'wind_gust_speed': 17.65, + 'wind_speed': 10.97, + }), + dict({ + 'apparent_temperature': 28.6, + 'cloud_coverage': 54.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-12T10:00:00Z', + 'dew_point': 21.4, + 'humidity': 75, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.23, + 'temperature': 26.2, + 'uv_index': 0, + 'wind_bearing': 143, + 'wind_gust_speed': 15.87, + 'wind_speed': 10.23, + }), + dict({ + 'apparent_temperature': 27.6, + 'cloud_coverage': 56.99999999999999, + 'condition': 'partlycloudy', + 'datetime': '2023-09-12T11:00:00Z', + 'dew_point': 21.3, + 'humidity': 78, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1011.79, + 'temperature': 25.4, + 'uv_index': 0, + 'wind_bearing': 146, + 'wind_gust_speed': 13.9, + 'wind_speed': 9.39, + }), + dict({ + 'apparent_temperature': 26.8, + 'cloud_coverage': 60.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-12T12:00:00Z', + 'dew_point': 21.2, + 'humidity': 81, + 'precipitation': 0.0, + 'precipitation_probability': 47.0, + 'pressure': 1012.12, + 'temperature': 24.7, + 'uv_index': 0, + 'wind_bearing': 147, + 'wind_gust_speed': 13.32, + 'wind_speed': 8.9, + }), + dict({ + 'apparent_temperature': 26.3, + 'cloud_coverage': 66.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T13:00:00Z', + 'dew_point': 21.2, + 'humidity': 83, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1012.18, + 'temperature': 24.2, + 'uv_index': 0, + 'wind_bearing': 149, + 'wind_gust_speed': 13.18, + 'wind_speed': 8.59, + }), + dict({ + 'apparent_temperature': 26.0, + 'cloud_coverage': 71.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T14:00:00Z', + 'dew_point': 21.3, + 'humidity': 85, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.09, + 'temperature': 23.9, + 'uv_index': 0, + 'wind_bearing': 149, + 'wind_gust_speed': 13.84, + 'wind_speed': 8.87, + }), + dict({ + 'apparent_temperature': 25.7, + 'cloud_coverage': 76.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T15:00:00Z', + 'dew_point': 21.3, + 'humidity': 87, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.99, + 'temperature': 23.6, + 'uv_index': 0, + 'wind_bearing': 149, + 'wind_gust_speed': 15.08, + 'wind_speed': 8.93, + }), + dict({ + 'apparent_temperature': 25.1, + 'cloud_coverage': 73.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T16:00:00Z', + 'dew_point': 21.0, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.93, + 'temperature': 23.2, + 'uv_index': 0, + 'wind_bearing': 146, + 'wind_gust_speed': 16.74, + 'wind_speed': 9.49, + }), + dict({ + 'apparent_temperature': 24.7, + 'cloud_coverage': 74.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T17:00:00Z', + 'dew_point': 20.8, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.75, + 'temperature': 22.9, + 'uv_index': 0, + 'wind_bearing': 146, + 'wind_gust_speed': 17.45, + 'wind_speed': 9.12, + }), + dict({ + 'apparent_temperature': 24.4, + 'cloud_coverage': 73.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T18:00:00Z', + 'dew_point': 20.7, + 'humidity': 89, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.77, + 'temperature': 22.6, + 'uv_index': 0, + 'wind_bearing': 149, + 'wind_gust_speed': 17.04, + 'wind_speed': 8.68, + }), + dict({ + 'apparent_temperature': 24.1, + 'cloud_coverage': 73.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T19:00:00Z', + 'dew_point': 20.6, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.93, + 'temperature': 22.4, + 'uv_index': 0, + 'wind_bearing': 149, + 'wind_gust_speed': 16.8, + 'wind_speed': 8.61, + }), + dict({ + 'apparent_temperature': 23.9, + 'cloud_coverage': 74.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T20:00:00Z', + 'dew_point': 20.5, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.23, + 'temperature': 22.1, + 'uv_index': 0, + 'wind_bearing': 150, + 'wind_gust_speed': 15.35, + 'wind_speed': 8.36, + }), + dict({ + 'apparent_temperature': 24.4, + 'cloud_coverage': 75.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T21:00:00Z', + 'dew_point': 20.6, + 'humidity': 89, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.49, + 'temperature': 22.6, + 'uv_index': 0, + 'wind_bearing': 155, + 'wind_gust_speed': 14.09, + 'wind_speed': 7.77, + }), + dict({ + 'apparent_temperature': 25.8, + 'cloud_coverage': 71.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T22:00:00Z', + 'dew_point': 21.0, + 'humidity': 84, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.72, + 'temperature': 23.8, + 'uv_index': 1, + 'wind_bearing': 152, + 'wind_gust_speed': 14.04, + 'wind_speed': 7.25, + }), + dict({ + 'apparent_temperature': 27.8, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T23:00:00Z', + 'dew_point': 21.4, + 'humidity': 78, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.85, + 'temperature': 25.5, + 'uv_index': 2, + 'wind_bearing': 149, + 'wind_gust_speed': 15.31, + 'wind_speed': 7.14, + }), + dict({ + 'apparent_temperature': 29.7, + 'cloud_coverage': 60.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-13T00:00:00Z', + 'dew_point': 21.8, + 'humidity': 73, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.89, + 'temperature': 27.1, + 'uv_index': 4, + 'wind_bearing': 141, + 'wind_gust_speed': 16.42, + 'wind_speed': 6.89, + }), + dict({ + 'apparent_temperature': 31.2, + 'cloud_coverage': 64.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T01:00:00Z', + 'dew_point': 22.0, + 'humidity': 68, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.65, + 'temperature': 28.4, + 'uv_index': 5, + 'wind_bearing': 137, + 'wind_gust_speed': 18.64, + 'wind_speed': 6.65, + }), + dict({ + 'apparent_temperature': 32.3, + 'cloud_coverage': 73.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T02:00:00Z', + 'dew_point': 21.9, + 'humidity': 64, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.26, + 'temperature': 29.4, + 'uv_index': 5, + 'wind_bearing': 128, + 'wind_gust_speed': 21.69, + 'wind_speed': 7.12, + }), + dict({ + 'apparent_temperature': 33.0, + 'cloud_coverage': 76.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T03:00:00Z', + 'dew_point': 21.9, + 'humidity': 62, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.88, + 'temperature': 30.1, + 'uv_index': 6, + 'wind_bearing': 111, + 'wind_gust_speed': 23.41, + 'wind_speed': 7.33, + }), + dict({ + 'apparent_temperature': 33.4, + 'cloud_coverage': 72.0, + 'condition': 'rainy', + 'datetime': '2023-09-13T04:00:00Z', + 'dew_point': 22.0, + 'humidity': 61, + 'precipitation': 0.9, + 'precipitation_probability': 12.0, + 'pressure': 1011.55, + 'temperature': 30.4, + 'uv_index': 5, + 'wind_bearing': 56, + 'wind_gust_speed': 23.1, + 'wind_speed': 8.09, + }), + dict({ + 'apparent_temperature': 33.2, + 'cloud_coverage': 72.0, + 'condition': 'rainy', + 'datetime': '2023-09-13T05:00:00Z', + 'dew_point': 21.9, + 'humidity': 61, + 'precipitation': 1.9, + 'precipitation_probability': 12.0, + 'pressure': 1011.29, + 'temperature': 30.2, + 'uv_index': 4, + 'wind_bearing': 20, + 'wind_gust_speed': 21.81, + 'wind_speed': 9.46, + }), + dict({ + 'apparent_temperature': 32.6, + 'cloud_coverage': 74.0, + 'condition': 'rainy', + 'datetime': '2023-09-13T06:00:00Z', + 'dew_point': 21.9, + 'humidity': 63, + 'precipitation': 2.3, + 'precipitation_probability': 11.0, + 'pressure': 1011.17, + 'temperature': 29.7, + 'uv_index': 3, + 'wind_bearing': 20, + 'wind_gust_speed': 19.72, + 'wind_speed': 9.8, + }), + dict({ + 'apparent_temperature': 31.8, + 'cloud_coverage': 69.0, + 'condition': 'rainy', + 'datetime': '2023-09-13T07:00:00Z', + 'dew_point': 22.4, + 'humidity': 68, + 'precipitation': 1.8, + 'precipitation_probability': 10.0, + 'pressure': 1011.32, + 'temperature': 28.8, + 'uv_index': 1, + 'wind_bearing': 18, + 'wind_gust_speed': 17.55, + 'wind_speed': 9.23, + }), + dict({ + 'apparent_temperature': 30.8, + 'cloud_coverage': 73.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T08:00:00Z', + 'dew_point': 22.9, + 'humidity': 76, + 'precipitation': 0.8, + 'precipitation_probability': 10.0, + 'pressure': 1011.6, + 'temperature': 27.6, + 'uv_index': 0, + 'wind_bearing': 27, + 'wind_gust_speed': 15.08, + 'wind_speed': 8.05, + }), + dict({ + 'apparent_temperature': 29.4, + 'cloud_coverage': 76.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T09:00:00Z', + 'dew_point': 23.0, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.94, + 'temperature': 26.3, + 'uv_index': 0, + 'wind_bearing': 32, + 'wind_gust_speed': 12.17, + 'wind_speed': 6.68, + }), + dict({ + 'apparent_temperature': 28.5, + 'cloud_coverage': 84.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T10:00:00Z', + 'dew_point': 22.9, + 'humidity': 85, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.3, + 'temperature': 25.5, + 'uv_index': 0, + 'wind_bearing': 69, + 'wind_gust_speed': 11.64, + 'wind_speed': 6.69, + }), + dict({ + 'apparent_temperature': 27.7, + 'cloud_coverage': 84.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T11:00:00Z', + 'dew_point': 22.6, + 'humidity': 87, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.71, + 'temperature': 25.0, + 'uv_index': 0, + 'wind_bearing': 155, + 'wind_gust_speed': 11.91, + 'wind_speed': 6.23, + }), + dict({ + 'apparent_temperature': 27.1, + 'cloud_coverage': 82.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T12:00:00Z', + 'dew_point': 22.3, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.96, + 'temperature': 24.5, + 'uv_index': 0, + 'wind_bearing': 161, + 'wind_gust_speed': 12.47, + 'wind_speed': 5.73, + }), + dict({ + 'apparent_temperature': 26.7, + 'cloud_coverage': 82.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T13:00:00Z', + 'dew_point': 22.3, + 'humidity': 89, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.03, + 'temperature': 24.2, + 'uv_index': 0, + 'wind_bearing': 161, + 'wind_gust_speed': 13.57, + 'wind_speed': 5.66, + }), + dict({ + 'apparent_temperature': 26.4, + 'cloud_coverage': 84.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T14:00:00Z', + 'dew_point': 22.2, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.99, + 'temperature': 23.9, + 'uv_index': 0, + 'wind_bearing': 159, + 'wind_gust_speed': 15.07, + 'wind_speed': 5.83, + }), + dict({ + 'apparent_temperature': 26.1, + 'cloud_coverage': 86.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T15:00:00Z', + 'dew_point': 22.2, + 'humidity': 91, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.95, + 'temperature': 23.7, + 'uv_index': 0, + 'wind_bearing': 158, + 'wind_gust_speed': 16.06, + 'wind_speed': 5.93, + }), + dict({ + 'apparent_temperature': 25.7, + 'cloud_coverage': 88.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T16:00:00Z', + 'dew_point': 22.0, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.9, + 'temperature': 23.4, + 'uv_index': 0, + 'wind_bearing': 153, + 'wind_gust_speed': 16.05, + 'wind_speed': 5.75, + }), + dict({ + 'apparent_temperature': 25.4, + 'cloud_coverage': 90.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T17:00:00Z', + 'dew_point': 21.8, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.85, + 'temperature': 23.1, + 'uv_index': 0, + 'wind_bearing': 150, + 'wind_gust_speed': 15.52, + 'wind_speed': 5.49, + }), + dict({ + 'apparent_temperature': 25.2, + 'cloud_coverage': 92.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T18:00:00Z', + 'dew_point': 21.8, + 'humidity': 93, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.87, + 'temperature': 23.0, + 'uv_index': 0, + 'wind_bearing': 149, + 'wind_gust_speed': 15.01, + 'wind_speed': 5.32, + }), + dict({ + 'apparent_temperature': 25.0, + 'cloud_coverage': 90.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T19:00:00Z', + 'dew_point': 21.7, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.01, + 'temperature': 22.8, + 'uv_index': 0, + 'wind_bearing': 147, + 'wind_gust_speed': 14.39, + 'wind_speed': 5.33, + }), + dict({ + 'apparent_temperature': 24.8, + 'cloud_coverage': 89.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T20:00:00Z', + 'dew_point': 21.6, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.22, + 'temperature': 22.6, + 'uv_index': 0, + 'wind_bearing': 147, + 'wind_gust_speed': 13.79, + 'wind_speed': 5.43, + }), + dict({ + 'apparent_temperature': 25.3, + 'cloud_coverage': 86.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T21:00:00Z', + 'dew_point': 21.8, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.41, + 'temperature': 23.1, + 'uv_index': 0, + 'wind_bearing': 147, + 'wind_gust_speed': 14.12, + 'wind_speed': 5.52, + }), + dict({ + 'apparent_temperature': 26.7, + 'cloud_coverage': 77.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T22:00:00Z', + 'dew_point': 22.1, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.59, + 'temperature': 24.3, + 'uv_index': 1, + 'wind_bearing': 147, + 'wind_gust_speed': 16.14, + 'wind_speed': 5.58, + }), + dict({ + 'apparent_temperature': 28.4, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T23:00:00Z', + 'dew_point': 22.4, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.74, + 'temperature': 25.7, + 'uv_index': 2, + 'wind_bearing': 146, + 'wind_gust_speed': 19.09, + 'wind_speed': 5.62, + }), + dict({ + 'apparent_temperature': 30.5, + 'cloud_coverage': 57.99999999999999, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T00:00:00Z', + 'dew_point': 22.9, + 'humidity': 76, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.78, + 'temperature': 27.4, + 'uv_index': 4, + 'wind_bearing': 143, + 'wind_gust_speed': 21.6, + 'wind_speed': 5.58, + }), + dict({ + 'apparent_temperature': 32.2, + 'cloud_coverage': 54.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T01:00:00Z', + 'dew_point': 23.2, + 'humidity': 72, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.61, + 'temperature': 28.7, + 'uv_index': 5, + 'wind_bearing': 138, + 'wind_gust_speed': 23.36, + 'wind_speed': 5.34, + }), + dict({ + 'apparent_temperature': 33.5, + 'cloud_coverage': 54.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T02:00:00Z', + 'dew_point': 23.2, + 'humidity': 68, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.32, + 'temperature': 29.9, + 'uv_index': 6, + 'wind_bearing': 111, + 'wind_gust_speed': 24.72, + 'wind_speed': 4.99, + }), + dict({ + 'apparent_temperature': 34.4, + 'cloud_coverage': 56.00000000000001, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T03:00:00Z', + 'dew_point': 23.3, + 'humidity': 65, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.04, + 'temperature': 30.7, + 'uv_index': 6, + 'wind_bearing': 354, + 'wind_gust_speed': 25.23, + 'wind_speed': 4.74, + }), + dict({ + 'apparent_temperature': 34.9, + 'cloud_coverage': 57.99999999999999, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T04:00:00Z', + 'dew_point': 23.4, + 'humidity': 64, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.77, + 'temperature': 31.0, + 'uv_index': 6, + 'wind_bearing': 341, + 'wind_gust_speed': 24.6, + 'wind_speed': 4.79, + }), + dict({ + 'apparent_temperature': 34.5, + 'cloud_coverage': 60.0, + 'condition': 'rainy', + 'datetime': '2023-09-14T05:00:00Z', + 'dew_point': 23.2, + 'humidity': 64, + 'precipitation': 0.2, + 'precipitation_probability': 15.0, + 'pressure': 1012.53, + 'temperature': 30.7, + 'uv_index': 5, + 'wind_bearing': 336, + 'wind_gust_speed': 23.28, + 'wind_speed': 5.07, + }), + dict({ + 'apparent_temperature': 33.8, + 'cloud_coverage': 59.0, + 'condition': 'rainy', + 'datetime': '2023-09-14T06:00:00Z', + 'dew_point': 23.1, + 'humidity': 66, + 'precipitation': 0.2, + 'precipitation_probability': 14.000000000000002, + 'pressure': 1012.49, + 'temperature': 30.2, + 'uv_index': 3, + 'wind_bearing': 336, + 'wind_gust_speed': 22.05, + 'wind_speed': 5.34, + }), + dict({ + 'apparent_temperature': 32.9, + 'cloud_coverage': 53.0, + 'condition': 'rainy', + 'datetime': '2023-09-14T07:00:00Z', + 'dew_point': 23.0, + 'humidity': 68, + 'precipitation': 0.2, + 'precipitation_probability': 40.0, + 'pressure': 1012.73, + 'temperature': 29.5, + 'uv_index': 2, + 'wind_bearing': 339, + 'wind_gust_speed': 21.18, + 'wind_speed': 5.63, + }), + dict({ + 'apparent_temperature': 31.6, + 'cloud_coverage': 43.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T08:00:00Z', + 'dew_point': 22.8, + 'humidity': 72, + 'precipitation': 0.0, + 'precipitation_probability': 45.0, + 'pressure': 1013.16, + 'temperature': 28.4, + 'uv_index': 0, + 'wind_bearing': 342, + 'wind_gust_speed': 20.35, + 'wind_speed': 5.93, + }), + dict({ + 'apparent_temperature': 30.0, + 'cloud_coverage': 35.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T09:00:00Z', + 'dew_point': 22.5, + 'humidity': 76, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1013.62, + 'temperature': 27.1, + 'uv_index': 0, + 'wind_bearing': 347, + 'wind_gust_speed': 19.42, + 'wind_speed': 5.95, + }), + dict({ + 'apparent_temperature': 29.0, + 'cloud_coverage': 32.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T10:00:00Z', + 'dew_point': 22.4, + 'humidity': 79, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1014.09, + 'temperature': 26.3, + 'uv_index': 0, + 'wind_bearing': 348, + 'wind_gust_speed': 18.19, + 'wind_speed': 5.31, + }), + dict({ + 'apparent_temperature': 28.2, + 'cloud_coverage': 31.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T11:00:00Z', + 'dew_point': 22.4, + 'humidity': 83, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1014.56, + 'temperature': 25.5, + 'uv_index': 0, + 'wind_bearing': 177, + 'wind_gust_speed': 16.79, + 'wind_speed': 4.28, + }), + dict({ + 'apparent_temperature': 27.5, + 'cloud_coverage': 31.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T12:00:00Z', + 'dew_point': 22.3, + 'humidity': 86, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1014.87, + 'temperature': 24.9, + 'uv_index': 0, + 'wind_bearing': 171, + 'wind_gust_speed': 15.61, + 'wind_speed': 3.72, + }), + dict({ + 'apparent_temperature': 26.6, + 'cloud_coverage': 31.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T13:00:00Z', + 'dew_point': 22.1, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1014.91, + 'temperature': 24.2, + 'uv_index': 0, + 'wind_bearing': 171, + 'wind_gust_speed': 14.7, + 'wind_speed': 4.11, + }), + dict({ + 'apparent_temperature': 25.9, + 'cloud_coverage': 32.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T14:00:00Z', + 'dew_point': 21.9, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1014.8, + 'temperature': 23.6, + 'uv_index': 0, + 'wind_bearing': 171, + 'wind_gust_speed': 13.81, + 'wind_speed': 4.97, + }), + dict({ + 'apparent_temperature': 25.3, + 'cloud_coverage': 34.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T15:00:00Z', + 'dew_point': 21.7, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1014.66, + 'temperature': 23.1, + 'uv_index': 0, + 'wind_bearing': 170, + 'wind_gust_speed': 12.88, + 'wind_speed': 5.57, + }), + dict({ + 'apparent_temperature': 24.8, + 'cloud_coverage': 37.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T16:00:00Z', + 'dew_point': 21.5, + 'humidity': 93, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1014.54, + 'temperature': 22.7, + 'uv_index': 0, + 'wind_bearing': 168, + 'wind_gust_speed': 12.0, + 'wind_speed': 5.62, + }), + dict({ + 'apparent_temperature': 24.4, + 'cloud_coverage': 39.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T17:00:00Z', + 'dew_point': 21.3, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1014.45, + 'temperature': 22.4, + 'uv_index': 0, + 'wind_bearing': 165, + 'wind_gust_speed': 11.43, + 'wind_speed': 5.48, + }), + dict({ + 'apparent_temperature': 24.6, + 'cloud_coverage': 40.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T18:00:00Z', + 'dew_point': 21.4, + 'humidity': 93, + 'precipitation': 0.0, + 'precipitation_probability': 44.0, + 'pressure': 1014.45, + 'temperature': 22.6, + 'uv_index': 0, + 'wind_bearing': 162, + 'wind_gust_speed': 11.42, + 'wind_speed': 5.38, + }), + dict({ + 'apparent_temperature': 25.0, + 'cloud_coverage': 40.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T19:00:00Z', + 'dew_point': 21.6, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 52.0, + 'pressure': 1014.63, + 'temperature': 22.9, + 'uv_index': 0, + 'wind_bearing': 161, + 'wind_gust_speed': 12.15, + 'wind_speed': 5.39, + }), + dict({ + 'apparent_temperature': 25.6, + 'cloud_coverage': 38.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T20:00:00Z', + 'dew_point': 21.8, + 'humidity': 91, + 'precipitation': 0.0, + 'precipitation_probability': 51.0, + 'pressure': 1014.91, + 'temperature': 23.4, + 'uv_index': 0, + 'wind_bearing': 159, + 'wind_gust_speed': 13.54, + 'wind_speed': 5.45, + }), + dict({ + 'apparent_temperature': 26.6, + 'cloud_coverage': 36.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T21:00:00Z', + 'dew_point': 22.0, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 42.0, + 'pressure': 1015.18, + 'temperature': 24.2, + 'uv_index': 0, + 'wind_bearing': 158, + 'wind_gust_speed': 15.48, + 'wind_speed': 5.62, + }), + dict({ + 'apparent_temperature': 28.5, + 'cloud_coverage': 32.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T22:00:00Z', + 'dew_point': 22.5, + 'humidity': 83, + 'precipitation': 0.0, + 'precipitation_probability': 28.999999999999996, + 'pressure': 1015.4, + 'temperature': 25.7, + 'uv_index': 1, + 'wind_bearing': 158, + 'wind_gust_speed': 17.86, + 'wind_speed': 5.84, + }), + dict({ + 'apparent_temperature': 30.3, + 'cloud_coverage': 30.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T23:00:00Z', + 'dew_point': 22.9, + 'humidity': 77, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.54, + 'temperature': 27.2, + 'uv_index': 2, + 'wind_bearing': 155, + 'wind_gust_speed': 20.19, + 'wind_speed': 6.09, + }), + dict({ + 'apparent_temperature': 32.1, + 'cloud_coverage': 30.0, + 'condition': 'sunny', + 'datetime': '2023-09-15T00:00:00Z', + 'dew_point': 23.3, + 'humidity': 73, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.55, + 'temperature': 28.6, + 'uv_index': 4, + 'wind_bearing': 152, + 'wind_gust_speed': 21.83, + 'wind_speed': 6.42, + }), + dict({ + 'apparent_temperature': 33.4, + 'cloud_coverage': 34.0, + 'condition': 'sunny', + 'datetime': '2023-09-15T01:00:00Z', + 'dew_point': 23.5, + 'humidity': 70, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.35, + 'temperature': 29.6, + 'uv_index': 6, + 'wind_bearing': 144, + 'wind_gust_speed': 22.56, + 'wind_speed': 6.91, + }), + dict({ + 'apparent_temperature': 34.2, + 'cloud_coverage': 41.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T02:00:00Z', + 'dew_point': 23.5, + 'humidity': 67, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.0, + 'temperature': 30.4, + 'uv_index': 7, + 'wind_bearing': 336, + 'wind_gust_speed': 22.83, + 'wind_speed': 7.47, + }), + dict({ + 'apparent_temperature': 34.9, + 'cloud_coverage': 46.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T03:00:00Z', + 'dew_point': 23.5, + 'humidity': 65, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.62, + 'temperature': 30.9, + 'uv_index': 7, + 'wind_bearing': 336, + 'wind_gust_speed': 22.98, + 'wind_speed': 7.95, + }), + dict({ + 'apparent_temperature': 35.4, + 'cloud_coverage': 46.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T04:00:00Z', + 'dew_point': 23.6, + 'humidity': 64, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.25, + 'temperature': 31.3, + 'uv_index': 6, + 'wind_bearing': 341, + 'wind_gust_speed': 23.21, + 'wind_speed': 8.44, + }), + dict({ + 'apparent_temperature': 35.6, + 'cloud_coverage': 44.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T05:00:00Z', + 'dew_point': 23.7, + 'humidity': 64, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.95, + 'temperature': 31.5, + 'uv_index': 5, + 'wind_bearing': 344, + 'wind_gust_speed': 23.46, + 'wind_speed': 8.95, + }), + dict({ + 'apparent_temperature': 35.1, + 'cloud_coverage': 42.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T06:00:00Z', + 'dew_point': 23.6, + 'humidity': 64, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.83, + 'temperature': 31.1, + 'uv_index': 3, + 'wind_bearing': 347, + 'wind_gust_speed': 23.64, + 'wind_speed': 9.13, + }), + dict({ + 'apparent_temperature': 34.1, + 'cloud_coverage': 41.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T07:00:00Z', + 'dew_point': 23.4, + 'humidity': 66, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.96, + 'temperature': 30.3, + 'uv_index': 2, + 'wind_bearing': 350, + 'wind_gust_speed': 23.66, + 'wind_speed': 8.78, + }), + dict({ + 'apparent_temperature': 32.4, + 'cloud_coverage': 40.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T08:00:00Z', + 'dew_point': 23.1, + 'humidity': 70, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.25, + 'temperature': 29.0, + 'uv_index': 0, + 'wind_bearing': 356, + 'wind_gust_speed': 23.51, + 'wind_speed': 8.13, + }), + dict({ + 'apparent_temperature': 31.1, + 'cloud_coverage': 41.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T09:00:00Z', + 'dew_point': 22.9, + 'humidity': 74, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.61, + 'temperature': 27.9, + 'uv_index': 0, + 'wind_bearing': 3, + 'wind_gust_speed': 23.21, + 'wind_speed': 7.48, + }), + dict({ + 'apparent_temperature': 30.0, + 'cloud_coverage': 43.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T10:00:00Z', + 'dew_point': 22.8, + 'humidity': 78, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.02, + 'temperature': 26.9, + 'uv_index': 0, + 'wind_bearing': 20, + 'wind_gust_speed': 22.68, + 'wind_speed': 6.83, + }), + dict({ + 'apparent_temperature': 29.2, + 'cloud_coverage': 46.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T11:00:00Z', + 'dew_point': 22.8, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.43, + 'temperature': 26.2, + 'uv_index': 0, + 'wind_bearing': 129, + 'wind_gust_speed': 22.04, + 'wind_speed': 6.1, + }), + dict({ + 'apparent_temperature': 28.4, + 'cloud_coverage': 48.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T12:00:00Z', + 'dew_point': 22.7, + 'humidity': 84, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.71, + 'temperature': 25.6, + 'uv_index': 0, + 'wind_bearing': 159, + 'wind_gust_speed': 21.64, + 'wind_speed': 5.6, + }), + dict({ + 'apparent_temperature': 28.2, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-15T13:00:00Z', + 'dew_point': 23.2, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.52, + 'temperature': 25.2, + 'uv_index': 0, + 'wind_bearing': 164, + 'wind_gust_speed': 16.35, + 'wind_speed': 5.58, + }), + dict({ + 'apparent_temperature': 27.4, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-15T14:00:00Z', + 'dew_point': 22.9, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.37, + 'temperature': 24.6, + 'uv_index': 0, + 'wind_bearing': 168, + 'wind_gust_speed': 17.11, + 'wind_speed': 5.79, + }), + dict({ + 'apparent_temperature': 26.9, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-15T15:00:00Z', + 'dew_point': 22.7, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.21, + 'temperature': 24.2, + 'uv_index': 0, + 'wind_bearing': 182, + 'wind_gust_speed': 17.32, + 'wind_speed': 5.77, + }), + dict({ + 'apparent_temperature': 26.4, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-15T16:00:00Z', + 'dew_point': 22.6, + 'humidity': 93, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.07, + 'temperature': 23.8, + 'uv_index': 0, + 'wind_bearing': 201, + 'wind_gust_speed': 16.6, + 'wind_speed': 5.27, + }), + dict({ + 'apparent_temperature': 26.0, + 'cloud_coverage': 66.0, + 'condition': 'cloudy', + 'datetime': '2023-09-15T17:00:00Z', + 'dew_point': 22.5, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.95, + 'temperature': 23.5, + 'uv_index': 0, + 'wind_bearing': 219, + 'wind_gust_speed': 15.52, + 'wind_speed': 4.62, + }), + dict({ + 'apparent_temperature': 25.7, + 'cloud_coverage': 66.0, + 'condition': 'cloudy', + 'datetime': '2023-09-15T18:00:00Z', + 'dew_point': 22.3, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.88, + 'temperature': 23.3, + 'uv_index': 0, + 'wind_bearing': 216, + 'wind_gust_speed': 14.64, + 'wind_speed': 4.32, + }), + dict({ + 'apparent_temperature': 26.0, + 'cloud_coverage': 66.0, + 'condition': 'cloudy', + 'datetime': '2023-09-15T19:00:00Z', + 'dew_point': 22.4, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.91, + 'temperature': 23.5, + 'uv_index': 0, + 'wind_bearing': 198, + 'wind_gust_speed': 14.06, + 'wind_speed': 4.73, + }), + dict({ + 'apparent_temperature': 26.3, + 'cloud_coverage': 66.0, + 'condition': 'cloudy', + 'datetime': '2023-09-15T20:00:00Z', + 'dew_point': 22.4, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.99, + 'temperature': 23.8, + 'uv_index': 0, + 'wind_bearing': 189, + 'wind_gust_speed': 13.7, + 'wind_speed': 5.49, + }), + dict({ + 'apparent_temperature': 27.1, + 'cloud_coverage': 64.0, + 'condition': 'cloudy', + 'datetime': '2023-09-15T21:00:00Z', + 'dew_point': 22.5, + 'humidity': 89, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.07, + 'temperature': 24.4, + 'uv_index': 0, + 'wind_bearing': 183, + 'wind_gust_speed': 13.77, + 'wind_speed': 5.95, + }), + dict({ + 'apparent_temperature': 28.3, + 'cloud_coverage': 59.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T22:00:00Z', + 'dew_point': 22.6, + 'humidity': 84, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.12, + 'temperature': 25.5, + 'uv_index': 1, + 'wind_bearing': 179, + 'wind_gust_speed': 14.38, + 'wind_speed': 5.77, + }), + dict({ + 'apparent_temperature': 29.9, + 'cloud_coverage': 52.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T23:00:00Z', + 'dew_point': 22.9, + 'humidity': 79, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.13, + 'temperature': 26.9, + 'uv_index': 2, + 'wind_bearing': 170, + 'wind_gust_speed': 15.2, + 'wind_speed': 5.27, + }), + dict({ + 'apparent_temperature': 31.2, + 'cloud_coverage': 44.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-16T00:00:00Z', + 'dew_point': 22.9, + 'humidity': 74, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.04, + 'temperature': 28.0, + 'uv_index': 4, + 'wind_bearing': 155, + 'wind_gust_speed': 15.85, + 'wind_speed': 4.76, + }), + dict({ + 'apparent_temperature': 32.5, + 'cloud_coverage': 24.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T01:00:00Z', + 'dew_point': 22.6, + 'humidity': 68, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.52, + 'temperature': 29.2, + 'uv_index': 6, + 'wind_bearing': 110, + 'wind_gust_speed': 16.27, + 'wind_speed': 6.81, + }), + dict({ + 'apparent_temperature': 33.5, + 'cloud_coverage': 16.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T02:00:00Z', + 'dew_point': 22.4, + 'humidity': 63, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.01, + 'temperature': 30.2, + 'uv_index': 8, + 'wind_bearing': 30, + 'wind_gust_speed': 16.55, + 'wind_speed': 6.86, + }), + dict({ + 'apparent_temperature': 34.2, + 'cloud_coverage': 10.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T03:00:00Z', + 'dew_point': 22.0, + 'humidity': 59, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.45, + 'temperature': 31.1, + 'uv_index': 8, + 'wind_bearing': 17, + 'wind_gust_speed': 16.52, + 'wind_speed': 6.8, + }), + dict({ + 'apparent_temperature': 34.7, + 'cloud_coverage': 10.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T04:00:00Z', + 'dew_point': 21.9, + 'humidity': 57, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.89, + 'temperature': 31.5, + 'uv_index': 8, + 'wind_bearing': 17, + 'wind_gust_speed': 16.08, + 'wind_speed': 6.62, + }), + dict({ + 'apparent_temperature': 34.9, + 'cloud_coverage': 10.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T05:00:00Z', + 'dew_point': 21.9, + 'humidity': 56, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.39, + 'temperature': 31.8, + 'uv_index': 6, + 'wind_bearing': 20, + 'wind_gust_speed': 15.48, + 'wind_speed': 6.45, + }), + dict({ + 'apparent_temperature': 34.5, + 'cloud_coverage': 10.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T06:00:00Z', + 'dew_point': 21.7, + 'humidity': 56, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.11, + 'temperature': 31.4, + 'uv_index': 4, + 'wind_bearing': 26, + 'wind_gust_speed': 15.08, + 'wind_speed': 6.43, + }), + dict({ + 'apparent_temperature': 33.6, + 'cloud_coverage': 7.000000000000001, + 'condition': 'sunny', + 'datetime': '2023-09-16T07:00:00Z', + 'dew_point': 21.7, + 'humidity': 59, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.15, + 'temperature': 30.7, + 'uv_index': 2, + 'wind_bearing': 39, + 'wind_gust_speed': 14.88, + 'wind_speed': 6.61, + }), + dict({ + 'apparent_temperature': 32.5, + 'cloud_coverage': 2.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T08:00:00Z', + 'dew_point': 21.9, + 'humidity': 63, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.41, + 'temperature': 29.6, + 'uv_index': 0, + 'wind_bearing': 72, + 'wind_gust_speed': 14.82, + 'wind_speed': 6.95, + }), + dict({ + 'apparent_temperature': 31.4, + 'cloud_coverage': 2.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T09:00:00Z', + 'dew_point': 22.1, + 'humidity': 68, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.75, + 'temperature': 28.5, + 'uv_index': 0, + 'wind_bearing': 116, + 'wind_gust_speed': 15.13, + 'wind_speed': 7.45, + }), + dict({ + 'apparent_temperature': 30.5, + 'cloud_coverage': 13.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T10:00:00Z', + 'dew_point': 22.3, + 'humidity': 73, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.13, + 'temperature': 27.6, + 'uv_index': 0, + 'wind_bearing': 140, + 'wind_gust_speed': 16.09, + 'wind_speed': 8.15, + }), + dict({ + 'apparent_temperature': 29.8, + 'cloud_coverage': 31.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T11:00:00Z', + 'dew_point': 22.6, + 'humidity': 78, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.47, + 'temperature': 26.9, + 'uv_index': 0, + 'wind_bearing': 149, + 'wind_gust_speed': 17.37, + 'wind_speed': 8.87, + }), + dict({ + 'apparent_temperature': 29.3, + 'cloud_coverage': 45.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-16T12:00:00Z', + 'dew_point': 22.9, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.6, + 'temperature': 26.3, + 'uv_index': 0, + 'wind_bearing': 155, + 'wind_gust_speed': 18.29, + 'wind_speed': 9.21, + }), + dict({ + 'apparent_temperature': 28.7, + 'cloud_coverage': 51.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-16T13:00:00Z', + 'dew_point': 23.0, + 'humidity': 85, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.41, + 'temperature': 25.7, + 'uv_index': 0, + 'wind_bearing': 159, + 'wind_gust_speed': 18.49, + 'wind_speed': 8.96, + }), + dict({ + 'apparent_temperature': 27.9, + 'cloud_coverage': 55.00000000000001, + 'condition': 'partlycloudy', + 'datetime': '2023-09-16T14:00:00Z', + 'dew_point': 22.8, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.01, + 'temperature': 25.0, + 'uv_index': 0, + 'wind_bearing': 162, + 'wind_gust_speed': 18.47, + 'wind_speed': 8.45, + }), + dict({ + 'apparent_temperature': 27.2, + 'cloud_coverage': 59.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-16T15:00:00Z', + 'dew_point': 22.7, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.55, + 'temperature': 24.5, + 'uv_index': 0, + 'wind_bearing': 162, + 'wind_gust_speed': 18.79, + 'wind_speed': 8.1, + }), + dict({ + 'apparent_temperature': 26.7, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-16T16:00:00Z', + 'dew_point': 22.6, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.1, + 'temperature': 24.0, + 'uv_index': 0, + 'wind_bearing': 162, + 'wind_gust_speed': 19.81, + 'wind_speed': 8.15, + }), + dict({ + 'apparent_temperature': 26.3, + 'cloud_coverage': 70.0, + 'condition': 'cloudy', + 'datetime': '2023-09-16T17:00:00Z', + 'dew_point': 22.6, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.68, + 'temperature': 23.7, + 'uv_index': 0, + 'wind_bearing': 161, + 'wind_gust_speed': 20.96, + 'wind_speed': 8.3, + }), + dict({ + 'apparent_temperature': 26.0, + 'cloud_coverage': 72.0, + 'condition': 'cloudy', + 'datetime': '2023-09-16T18:00:00Z', + 'dew_point': 22.4, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.39, + 'temperature': 23.5, + 'uv_index': 0, + 'wind_bearing': 159, + 'wind_gust_speed': 21.41, + 'wind_speed': 8.24, + }), + dict({ + 'apparent_temperature': 26.3, + 'cloud_coverage': 70.0, + 'condition': 'cloudy', + 'datetime': '2023-09-16T19:00:00Z', + 'dew_point': 22.5, + 'humidity': 93, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.29, + 'temperature': 23.8, + 'uv_index': 0, + 'wind_bearing': 159, + 'wind_gust_speed': 20.42, + 'wind_speed': 7.62, + }), + dict({ + 'apparent_temperature': 26.8, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-16T20:00:00Z', + 'dew_point': 22.6, + 'humidity': 91, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.31, + 'temperature': 24.2, + 'uv_index': 0, + 'wind_bearing': 158, + 'wind_gust_speed': 18.61, + 'wind_speed': 6.66, + }), + dict({ + 'apparent_temperature': 27.7, + 'cloud_coverage': 57.99999999999999, + 'condition': 'partlycloudy', + 'datetime': '2023-09-16T21:00:00Z', + 'dew_point': 22.6, + 'humidity': 87, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.37, + 'temperature': 24.9, + 'uv_index': 0, + 'wind_bearing': 158, + 'wind_gust_speed': 17.14, + 'wind_speed': 5.86, + }), + dict({ + 'apparent_temperature': 28.9, + 'cloud_coverage': 48.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-16T22:00:00Z', + 'dew_point': 22.6, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.46, + 'temperature': 26.0, + 'uv_index': 1, + 'wind_bearing': 161, + 'wind_gust_speed': 16.78, + 'wind_speed': 5.5, + }), + dict({ + 'apparent_temperature': 30.6, + 'cloud_coverage': 39.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-16T23:00:00Z', + 'dew_point': 22.9, + 'humidity': 76, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.51, + 'temperature': 27.5, + 'uv_index': 2, + 'wind_bearing': 165, + 'wind_gust_speed': 17.21, + 'wind_speed': 5.56, + }), + dict({ + 'apparent_temperature': 31.7, + 'cloud_coverage': 33.0, + 'condition': 'sunny', + 'datetime': '2023-09-17T00:00:00Z', + 'dew_point': 22.8, + 'humidity': 71, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.39, + 'temperature': 28.5, + 'uv_index': 4, + 'wind_bearing': 174, + 'wind_gust_speed': 17.96, + 'wind_speed': 6.04, + }), + dict({ + 'apparent_temperature': 32.6, + 'cloud_coverage': 30.0, + 'condition': 'sunny', + 'datetime': '2023-09-17T01:00:00Z', + 'dew_point': 22.7, + 'humidity': 68, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.98, + 'temperature': 29.4, + 'uv_index': 6, + 'wind_bearing': 192, + 'wind_gust_speed': 19.15, + 'wind_speed': 7.23, + }), + dict({ + 'apparent_temperature': 33.6, + 'cloud_coverage': 28.999999999999996, + 'condition': 'sunny', + 'datetime': '2023-09-17T02:00:00Z', + 'dew_point': 22.8, + 'humidity': 65, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.38, + 'temperature': 30.1, + 'uv_index': 7, + 'wind_bearing': 225, + 'wind_gust_speed': 20.89, + 'wind_speed': 8.9, + }), + dict({ + 'apparent_temperature': 34.1, + 'cloud_coverage': 30.0, + 'condition': 'sunny', + 'datetime': '2023-09-17T03:00:00Z', + 'dew_point': 22.8, + 'humidity': 63, + 'precipitation': 0.3, + 'precipitation_probability': 9.0, + 'pressure': 1009.75, + 'temperature': 30.7, + 'uv_index': 8, + 'wind_bearing': 264, + 'wind_gust_speed': 22.67, + 'wind_speed': 10.27, + }), + dict({ + 'apparent_temperature': 33.9, + 'cloud_coverage': 37.0, + 'condition': 'sunny', + 'datetime': '2023-09-17T04:00:00Z', + 'dew_point': 22.5, + 'humidity': 62, + 'precipitation': 0.4, + 'precipitation_probability': 10.0, + 'pressure': 1009.18, + 'temperature': 30.5, + 'uv_index': 7, + 'wind_bearing': 293, + 'wind_gust_speed': 23.93, + 'wind_speed': 10.82, + }), + dict({ + 'apparent_temperature': 33.4, + 'cloud_coverage': 45.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-17T05:00:00Z', + 'dew_point': 22.4, + 'humidity': 63, + 'precipitation': 0.6, + 'precipitation_probability': 12.0, + 'pressure': 1008.71, + 'temperature': 30.1, + 'uv_index': 5, + 'wind_bearing': 308, + 'wind_gust_speed': 24.39, + 'wind_speed': 10.72, + }), + dict({ + 'apparent_temperature': 32.7, + 'cloud_coverage': 50.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-17T06:00:00Z', + 'dew_point': 22.2, + 'humidity': 64, + 'precipitation': 0.7, + 'precipitation_probability': 14.000000000000002, + 'pressure': 1008.46, + 'temperature': 29.6, + 'uv_index': 3, + 'wind_bearing': 312, + 'wind_gust_speed': 23.9, + 'wind_speed': 10.28, + }), + dict({ + 'apparent_temperature': 31.8, + 'cloud_coverage': 47.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-17T07:00:00Z', + 'dew_point': 22.1, + 'humidity': 67, + 'precipitation': 0.7, + 'precipitation_probability': 14.000000000000002, + 'pressure': 1008.53, + 'temperature': 28.9, + 'uv_index': 1, + 'wind_bearing': 312, + 'wind_gust_speed': 22.3, + 'wind_speed': 9.59, + }), + dict({ + 'apparent_temperature': 30.6, + 'cloud_coverage': 41.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-17T08:00:00Z', + 'dew_point': 21.9, + 'humidity': 70, + 'precipitation': 0.6, + 'precipitation_probability': 15.0, + 'pressure': 1008.82, + 'temperature': 27.9, + 'uv_index': 0, + 'wind_bearing': 305, + 'wind_gust_speed': 19.73, + 'wind_speed': 8.58, + }), + dict({ + 'apparent_temperature': 29.6, + 'cloud_coverage': 35.0, + 'condition': 'sunny', + 'datetime': '2023-09-17T09:00:00Z', + 'dew_point': 22.0, + 'humidity': 74, + 'precipitation': 0.5, + 'precipitation_probability': 15.0, + 'pressure': 1009.21, + 'temperature': 27.0, + 'uv_index': 0, + 'wind_bearing': 291, + 'wind_gust_speed': 16.49, + 'wind_speed': 7.34, + }), + dict({ + 'apparent_temperature': 28.6, + 'cloud_coverage': 33.0, + 'condition': 'sunny', + 'datetime': '2023-09-17T10:00:00Z', + 'dew_point': 21.9, + 'humidity': 78, + 'precipitation': 0.4, + 'precipitation_probability': 14.000000000000002, + 'pressure': 1009.65, + 'temperature': 26.1, + 'uv_index': 0, + 'wind_bearing': 257, + 'wind_gust_speed': 12.71, + 'wind_speed': 5.91, + }), + dict({ + 'apparent_temperature': 27.8, + 'cloud_coverage': 34.0, + 'condition': 'sunny', + 'datetime': '2023-09-17T11:00:00Z', + 'dew_point': 21.9, + 'humidity': 82, + 'precipitation': 0.3, + 'precipitation_probability': 14.000000000000002, + 'pressure': 1010.04, + 'temperature': 25.3, + 'uv_index': 0, + 'wind_bearing': 212, + 'wind_gust_speed': 9.16, + 'wind_speed': 4.54, + }), + dict({ + 'apparent_temperature': 27.1, + 'cloud_coverage': 36.0, + 'condition': 'sunny', + 'datetime': '2023-09-17T12:00:00Z', + 'dew_point': 21.9, + 'humidity': 85, + 'precipitation': 0.3, + 'precipitation_probability': 28.000000000000004, + 'pressure': 1010.24, + 'temperature': 24.6, + 'uv_index': 0, + 'wind_bearing': 192, + 'wind_gust_speed': 7.09, + 'wind_speed': 3.62, + }), + dict({ + 'apparent_temperature': 26.5, + 'cloud_coverage': 40.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-17T13:00:00Z', + 'dew_point': 22.0, + 'humidity': 88, + 'precipitation': 0.3, + 'precipitation_probability': 30.0, + 'pressure': 1010.15, + 'temperature': 24.1, + 'uv_index': 0, + 'wind_bearing': 185, + 'wind_gust_speed': 7.2, + 'wind_speed': 3.27, + }), + dict({ + 'apparent_temperature': 25.9, + 'cloud_coverage': 44.0, + 'condition': 'rainy', + 'datetime': '2023-09-17T14:00:00Z', + 'dew_point': 21.8, + 'humidity': 90, + 'precipitation': 0.3, + 'precipitation_probability': 30.0, + 'pressure': 1009.87, + 'temperature': 23.6, + 'uv_index': 0, + 'wind_bearing': 182, + 'wind_gust_speed': 8.37, + 'wind_speed': 3.22, + }), + dict({ + 'apparent_temperature': 25.5, + 'cloud_coverage': 49.0, + 'condition': 'rainy', + 'datetime': '2023-09-17T15:00:00Z', + 'dew_point': 21.8, + 'humidity': 92, + 'precipitation': 0.2, + 'precipitation_probability': 31.0, + 'pressure': 1009.56, + 'temperature': 23.2, + 'uv_index': 0, + 'wind_bearing': 180, + 'wind_gust_speed': 9.21, + 'wind_speed': 3.3, + }), + dict({ + 'apparent_temperature': 25.1, + 'cloud_coverage': 53.0, + 'condition': 'rainy', + 'datetime': '2023-09-17T16:00:00Z', + 'dew_point': 21.8, + 'humidity': 94, + 'precipitation': 0.2, + 'precipitation_probability': 33.0, + 'pressure': 1009.29, + 'temperature': 22.9, + 'uv_index': 0, + 'wind_bearing': 182, + 'wind_gust_speed': 9.0, + 'wind_speed': 3.46, + }), + dict({ + 'apparent_temperature': 24.8, + 'cloud_coverage': 56.00000000000001, + 'condition': 'partlycloudy', + 'datetime': '2023-09-17T17:00:00Z', + 'dew_point': 21.7, + 'humidity': 95, + 'precipitation': 0.0, + 'precipitation_probability': 35.0, + 'pressure': 1009.09, + 'temperature': 22.6, + 'uv_index': 0, + 'wind_bearing': 186, + 'wind_gust_speed': 8.37, + 'wind_speed': 3.72, + }), + dict({ + 'apparent_temperature': 24.6, + 'cloud_coverage': 59.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-17T18:00:00Z', + 'dew_point': 21.6, + 'humidity': 95, + 'precipitation': 0.0, + 'precipitation_probability': 37.0, + 'pressure': 1009.01, + 'temperature': 22.5, + 'uv_index': 0, + 'wind_bearing': 201, + 'wind_gust_speed': 7.99, + 'wind_speed': 4.07, + }), + dict({ + 'apparent_temperature': 24.9, + 'cloud_coverage': 62.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-17T19:00:00Z', + 'dew_point': 21.7, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 39.0, + 'pressure': 1009.07, + 'temperature': 22.7, + 'uv_index': 0, + 'wind_bearing': 258, + 'wind_gust_speed': 8.18, + 'wind_speed': 4.55, + }), + dict({ + 'apparent_temperature': 25.2, + 'cloud_coverage': 64.0, + 'condition': 'cloudy', + 'datetime': '2023-09-17T20:00:00Z', + 'dew_point': 21.7, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 39.0, + 'pressure': 1009.23, + 'temperature': 23.0, + 'uv_index': 0, + 'wind_bearing': 305, + 'wind_gust_speed': 8.77, + 'wind_speed': 5.17, + }), + dict({ + 'apparent_temperature': 25.8, + 'cloud_coverage': 68.0, + 'condition': 'cloudy', + 'datetime': '2023-09-17T21:00:00Z', + 'dew_point': 21.8, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 38.0, + 'pressure': 1009.47, + 'temperature': 23.5, + 'uv_index': 0, + 'wind_bearing': 318, + 'wind_gust_speed': 9.69, + 'wind_speed': 5.77, + }), + dict({ + 'apparent_temperature': 26.5, + 'cloud_coverage': 74.0, + 'condition': 'cloudy', + 'datetime': '2023-09-17T22:00:00Z', + 'dew_point': 21.8, + 'humidity': 86, + 'precipitation': 0.0, + 'precipitation_probability': 30.0, + 'pressure': 1009.77, + 'temperature': 24.2, + 'uv_index': 1, + 'wind_bearing': 324, + 'wind_gust_speed': 10.88, + 'wind_speed': 6.26, + }), + dict({ + 'apparent_temperature': 27.6, + 'cloud_coverage': 80.0, + 'condition': 'rainy', + 'datetime': '2023-09-17T23:00:00Z', + 'dew_point': 21.9, + 'humidity': 83, + 'precipitation': 0.2, + 'precipitation_probability': 15.0, + 'pressure': 1010.09, + 'temperature': 25.1, + 'uv_index': 2, + 'wind_bearing': 329, + 'wind_gust_speed': 12.21, + 'wind_speed': 6.68, + }), + dict({ + 'apparent_temperature': 28.2, + 'cloud_coverage': 87.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T00:00:00Z', + 'dew_point': 21.9, + 'humidity': 80, + 'precipitation': 0.2, + 'precipitation_probability': 15.0, + 'pressure': 1010.33, + 'temperature': 25.7, + 'uv_index': 3, + 'wind_bearing': 332, + 'wind_gust_speed': 13.52, + 'wind_speed': 7.12, + }), + dict({ + 'apparent_temperature': 29.8, + 'cloud_coverage': 67.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T01:00:00Z', + 'dew_point': 21.7, + 'humidity': 72, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1007.43, + 'temperature': 27.2, + 'uv_index': 5, + 'wind_bearing': 330, + 'wind_gust_speed': 11.36, + 'wind_speed': 11.36, + }), + dict({ + 'apparent_temperature': 30.1, + 'cloud_coverage': 70.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T02:00:00Z', + 'dew_point': 21.6, + 'humidity': 70, + 'precipitation': 0.3, + 'precipitation_probability': 9.0, + 'pressure': 1007.05, + 'temperature': 27.5, + 'uv_index': 6, + 'wind_bearing': 332, + 'wind_gust_speed': 12.06, + 'wind_speed': 12.06, + }), + dict({ + 'apparent_temperature': 30.3, + 'cloud_coverage': 71.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T03:00:00Z', + 'dew_point': 21.6, + 'humidity': 69, + 'precipitation': 0.5, + 'precipitation_probability': 10.0, + 'pressure': 1006.67, + 'temperature': 27.8, + 'uv_index': 6, + 'wind_bearing': 333, + 'wind_gust_speed': 12.81, + 'wind_speed': 12.81, + }), + dict({ + 'apparent_temperature': 30.6, + 'cloud_coverage': 67.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T04:00:00Z', + 'dew_point': 21.5, + 'humidity': 68, + 'precipitation': 0.4, + 'precipitation_probability': 10.0, + 'pressure': 1006.28, + 'temperature': 28.0, + 'uv_index': 5, + 'wind_bearing': 335, + 'wind_gust_speed': 13.68, + 'wind_speed': 13.68, + }), + dict({ + 'apparent_temperature': 30.7, + 'cloud_coverage': 60.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-18T05:00:00Z', + 'dew_point': 21.4, + 'humidity': 67, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1005.89, + 'temperature': 28.1, + 'uv_index': 4, + 'wind_bearing': 336, + 'wind_gust_speed': 14.61, + 'wind_speed': 14.61, + }), + dict({ + 'apparent_temperature': 30.3, + 'cloud_coverage': 56.99999999999999, + 'condition': 'partlycloudy', + 'datetime': '2023-09-18T06:00:00Z', + 'dew_point': 21.2, + 'humidity': 67, + 'precipitation': 0.0, + 'precipitation_probability': 27.0, + 'pressure': 1005.67, + 'temperature': 27.9, + 'uv_index': 3, + 'wind_bearing': 338, + 'wind_gust_speed': 15.25, + 'wind_speed': 15.25, + }), + dict({ + 'apparent_temperature': 29.8, + 'cloud_coverage': 60.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-18T07:00:00Z', + 'dew_point': 21.3, + 'humidity': 69, + 'precipitation': 0.0, + 'precipitation_probability': 28.000000000000004, + 'pressure': 1005.74, + 'temperature': 27.4, + 'uv_index': 1, + 'wind_bearing': 339, + 'wind_gust_speed': 15.45, + 'wind_speed': 15.45, + }), + dict({ + 'apparent_temperature': 29.1, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T08:00:00Z', + 'dew_point': 21.4, + 'humidity': 73, + 'precipitation': 0.0, + 'precipitation_probability': 26.0, + 'pressure': 1005.98, + 'temperature': 26.7, + 'uv_index': 0, + 'wind_bearing': 341, + 'wind_gust_speed': 15.38, + 'wind_speed': 15.38, + }), + dict({ + 'apparent_temperature': 28.6, + 'cloud_coverage': 68.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T09:00:00Z', + 'dew_point': 21.6, + 'humidity': 76, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1006.22, + 'temperature': 26.1, + 'uv_index': 0, + 'wind_bearing': 341, + 'wind_gust_speed': 15.27, + 'wind_speed': 15.27, + }), + dict({ + 'apparent_temperature': 27.9, + 'cloud_coverage': 66.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T10:00:00Z', + 'dew_point': 21.6, + 'humidity': 79, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1006.44, + 'temperature': 25.6, + 'uv_index': 0, + 'wind_bearing': 339, + 'wind_gust_speed': 15.09, + 'wind_speed': 15.09, + }), + dict({ + 'apparent_temperature': 27.6, + 'cloud_coverage': 61.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-18T11:00:00Z', + 'dew_point': 21.7, + 'humidity': 81, + 'precipitation': 0.0, + 'precipitation_probability': 26.0, + 'pressure': 1006.66, + 'temperature': 25.2, + 'uv_index': 0, + 'wind_bearing': 336, + 'wind_gust_speed': 14.88, + 'wind_speed': 14.88, + }), + dict({ + 'apparent_temperature': 27.2, + 'cloud_coverage': 61.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-18T12:00:00Z', + 'dew_point': 21.8, + 'humidity': 83, + 'precipitation': 0.0, + 'precipitation_probability': 26.0, + 'pressure': 1006.79, + 'temperature': 24.8, + 'uv_index': 0, + 'wind_bearing': 333, + 'wind_gust_speed': 14.91, + 'wind_speed': 14.91, + }), + dict({ + 'apparent_temperature': 25.7, + 'cloud_coverage': 38.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-18T13:00:00Z', + 'dew_point': 21.2, + 'humidity': 86, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.36, + 'temperature': 23.6, + 'uv_index': 0, + 'wind_bearing': 83, + 'wind_gust_speed': 4.58, + 'wind_speed': 3.16, + }), + dict({ + 'apparent_temperature': 25.1, + 'cloud_coverage': 74.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T14:00:00Z', + 'dew_point': 21.2, + 'humidity': 89, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.96, + 'temperature': 23.1, + 'uv_index': 0, + 'wind_bearing': 144, + 'wind_gust_speed': 4.74, + 'wind_speed': 4.52, + }), + dict({ + 'apparent_temperature': 24.5, + 'cloud_coverage': 100.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T15:00:00Z', + 'dew_point': 20.9, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.6, + 'temperature': 22.6, + 'uv_index': 0, + 'wind_bearing': 152, + 'wind_gust_speed': 5.63, + 'wind_speed': 5.63, + }), + dict({ + 'apparent_temperature': 24.0, + 'cloud_coverage': 100.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T16:00:00Z', + 'dew_point': 20.7, + 'humidity': 91, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.37, + 'temperature': 22.3, + 'uv_index': 0, + 'wind_bearing': 156, + 'wind_gust_speed': 6.02, + 'wind_speed': 6.02, + }), + dict({ + 'apparent_temperature': 23.7, + 'cloud_coverage': 100.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T17:00:00Z', + 'dew_point': 20.4, + 'humidity': 91, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.2, + 'temperature': 22.0, + 'uv_index': 0, + 'wind_bearing': 162, + 'wind_gust_speed': 6.15, + 'wind_speed': 6.15, + }), + dict({ + 'apparent_temperature': 23.4, + 'cloud_coverage': 100.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T18:00:00Z', + 'dew_point': 20.2, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.08, + 'temperature': 21.9, + 'uv_index': 0, + 'wind_bearing': 167, + 'wind_gust_speed': 6.48, + 'wind_speed': 6.48, + }), + dict({ + 'apparent_temperature': 23.2, + 'cloud_coverage': 100.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T19:00:00Z', + 'dew_point': 19.8, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.04, + 'temperature': 21.8, + 'uv_index': 0, + 'wind_bearing': 165, + 'wind_gust_speed': 7.51, + 'wind_speed': 7.51, + }), + dict({ + 'apparent_temperature': 23.4, + 'cloud_coverage': 99.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T20:00:00Z', + 'dew_point': 19.6, + 'humidity': 86, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.05, + 'temperature': 22.0, + 'uv_index': 0, + 'wind_bearing': 162, + 'wind_gust_speed': 8.73, + 'wind_speed': 8.73, + }), + dict({ + 'apparent_temperature': 23.9, + 'cloud_coverage': 98.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T21:00:00Z', + 'dew_point': 19.5, + 'humidity': 83, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.06, + 'temperature': 22.5, + 'uv_index': 0, + 'wind_bearing': 164, + 'wind_gust_speed': 9.21, + 'wind_speed': 9.11, + }), + dict({ + 'apparent_temperature': 25.3, + 'cloud_coverage': 96.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T22:00:00Z', + 'dew_point': 19.7, + 'humidity': 78, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.09, + 'temperature': 23.8, + 'uv_index': 1, + 'wind_bearing': 171, + 'wind_gust_speed': 9.03, + 'wind_speed': 7.91, + }), + ]), + }) +# --- diff --git a/tests/components/weatherkit/test_config_flow.py b/tests/components/weatherkit/test_config_flow.py new file mode 100644 index 00000000000000..3b6cf76a3d53db --- /dev/null +++ b/tests/components/weatherkit/test_config_flow.py @@ -0,0 +1,128 @@ +"""Test the Apple WeatherKit config flow.""" +from unittest.mock import AsyncMock, patch + +from apple_weatherkit import DataSetType +from apple_weatherkit.client import ( + WeatherKitApiClientAuthenticationError, + WeatherKitApiClientCommunicationError, + WeatherKitApiClientError, +) +import pytest + +from homeassistant import config_entries +from homeassistant.components.weatherkit.config_flow import ( + WeatherKitUnsupportedLocationError, +) +from homeassistant.components.weatherkit.const import ( + CONF_KEY_ID, + CONF_KEY_PEM, + CONF_SERVICE_ID, + CONF_TEAM_ID, + DOMAIN, +) +from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import EXAMPLE_CONFIG_DATA + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + +EXAMPLE_USER_INPUT = { + CONF_LOCATION: { + CONF_LATITUDE: 35.4690101707532, + CONF_LONGITUDE: 135.74817234593166, + }, + CONF_KEY_ID: "QABCDEFG123", + CONF_SERVICE_ID: "io.home-assistant.testing", + CONF_TEAM_ID: "ABCD123456", + CONF_KEY_PEM: "-----BEGIN PRIVATE KEY-----\nwhateverkey\n-----END PRIVATE KEY-----", +} + + +async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test we get the form and create an entry.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.weatherkit.WeatherKitApiClient.get_availability", + return_value=[DataSetType.CURRENT_WEATHER], + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + EXAMPLE_USER_INPUT, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + + location = EXAMPLE_USER_INPUT[CONF_LOCATION] + assert result["title"] == f"{location[CONF_LATITUDE]}, {location[CONF_LONGITUDE]}" + + assert result["data"] == EXAMPLE_CONFIG_DATA + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("exception", "expected_error"), + [ + (WeatherKitApiClientAuthenticationError, "invalid_auth"), + (WeatherKitApiClientCommunicationError, "cannot_connect"), + (WeatherKitUnsupportedLocationError, "unsupported_location"), + (WeatherKitApiClientError, "unknown"), + ], +) +async def test_error_handling( + hass: HomeAssistant, exception: Exception, expected_error: str +) -> None: + """Test that we handle various exceptions and generate appropriate errors.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.weatherkit.WeatherKitApiClient.get_availability", + side_effect=exception, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + EXAMPLE_USER_INPUT, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": expected_error} + + +async def test_form_unsupported_location(hass: HomeAssistant) -> None: + """Test we handle when WeatherKit does not support the location.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.weatherkit.WeatherKitApiClient.get_availability", + return_value=[], + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + EXAMPLE_USER_INPUT, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "unsupported_location"} + + # Test that we can recover from this error by changing the location + with patch( + "homeassistant.components.weatherkit.WeatherKitApiClient.get_availability", + return_value=[DataSetType.CURRENT_WEATHER], + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + EXAMPLE_USER_INPUT, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY diff --git a/tests/components/weatherkit/test_coordinator.py b/tests/components/weatherkit/test_coordinator.py new file mode 100644 index 00000000000000..f619ace237ae3b --- /dev/null +++ b/tests/components/weatherkit/test_coordinator.py @@ -0,0 +1,32 @@ +"""Test WeatherKit data coordinator.""" +from datetime import timedelta +from unittest.mock import patch + +from apple_weatherkit.client import WeatherKitApiClientError + +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.util.dt import utcnow + +from . import init_integration + +from tests.common import async_fire_time_changed + + +async def test_failed_updates(hass: HomeAssistant) -> None: + """Test that we properly handle failed updates.""" + await init_integration(hass) + + with patch( + "homeassistant.components.weatherkit.WeatherKitApiClient.get_weather_data", + side_effect=WeatherKitApiClientError, + ): + async_fire_time_changed( + hass, + utcnow() + timedelta(minutes=15), + ) + await hass.async_block_till_done() + + state = hass.states.get("weather.home") + assert state + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/weatherkit/test_setup.py b/tests/components/weatherkit/test_setup.py new file mode 100644 index 00000000000000..d71ecbda1b0218 --- /dev/null +++ b/tests/components/weatherkit/test_setup.py @@ -0,0 +1,61 @@ +"""Test the WeatherKit setup process.""" +from unittest.mock import patch + +from apple_weatherkit.client import ( + WeatherKitApiClientAuthenticationError, + WeatherKitApiClientError, +) + +from homeassistant import config_entries +from homeassistant.components.weatherkit.const import DOMAIN +from homeassistant.core import HomeAssistant + +from . import EXAMPLE_CONFIG_DATA + +from tests.common import MockConfigEntry + + +async def test_auth_error_handling(hass: HomeAssistant) -> None: + """Test that we handle authentication errors at setup properly.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="Home", + unique_id="0123456", + data=EXAMPLE_CONFIG_DATA, + ) + + with patch( + "homeassistant.components.weatherkit.WeatherKitApiClient.get_weather_data", + side_effect=WeatherKitApiClientAuthenticationError, + ), patch( + "homeassistant.components.weatherkit.WeatherKitApiClient.get_availability", + side_effect=WeatherKitApiClientAuthenticationError, + ): + entry.add_to_hass(hass) + setup_result = await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert setup_result is False + + +async def test_client_error_handling(hass: HomeAssistant) -> None: + """Test that we handle API client errors at setup properly.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="Home", + unique_id="0123456", + data=EXAMPLE_CONFIG_DATA, + ) + + with patch( + "homeassistant.components.weatherkit.WeatherKitApiClient.get_weather_data", + side_effect=WeatherKitApiClientError, + ), patch( + "homeassistant.components.weatherkit.WeatherKitApiClient.get_availability", + side_effect=WeatherKitApiClientError, + ): + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state == config_entries.ConfigEntryState.SETUP_RETRY diff --git a/tests/components/weatherkit/test_weather.py b/tests/components/weatherkit/test_weather.py new file mode 100644 index 00000000000000..fabd3aab572d37 --- /dev/null +++ b/tests/components/weatherkit/test_weather.py @@ -0,0 +1,115 @@ +"""Weather entity tests for the WeatherKit integration.""" + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.weather import ( + ATTR_WEATHER_APPARENT_TEMPERATURE, + ATTR_WEATHER_CLOUD_COVERAGE, + ATTR_WEATHER_DEW_POINT, + ATTR_WEATHER_HUMIDITY, + ATTR_WEATHER_PRESSURE, + ATTR_WEATHER_TEMPERATURE, + ATTR_WEATHER_UV_INDEX, + ATTR_WEATHER_VISIBILITY, + ATTR_WEATHER_WIND_BEARING, + ATTR_WEATHER_WIND_GUST_SPEED, + ATTR_WEATHER_WIND_SPEED, + DOMAIN as WEATHER_DOMAIN, + SERVICE_GET_FORECAST, +) +from homeassistant.components.weather.const import WeatherEntityFeature +from homeassistant.components.weatherkit.const import ATTRIBUTION +from homeassistant.const import ATTR_ATTRIBUTION, ATTR_SUPPORTED_FEATURES +from homeassistant.core import HomeAssistant + +from . import init_integration + + +async def test_current_weather(hass: HomeAssistant) -> None: + """Test states of the current weather.""" + await init_integration(hass) + + state = hass.states.get("weather.home") + assert state + assert state.state == "partlycloudy" + assert state.attributes[ATTR_WEATHER_HUMIDITY] == 91 + assert state.attributes[ATTR_WEATHER_PRESSURE] == 1009.8 + assert state.attributes[ATTR_WEATHER_TEMPERATURE] == 22.9 + assert state.attributes[ATTR_WEATHER_VISIBILITY] == 20.97 + assert state.attributes[ATTR_WEATHER_WIND_BEARING] == 259 + assert state.attributes[ATTR_WEATHER_WIND_SPEED] == 5.23 + assert state.attributes[ATTR_WEATHER_APPARENT_TEMPERATURE] == 24.9 + assert state.attributes[ATTR_WEATHER_DEW_POINT] == 21.3 + assert state.attributes[ATTR_WEATHER_CLOUD_COVERAGE] == 62 + assert state.attributes[ATTR_WEATHER_WIND_GUST_SPEED] == 10.53 + assert state.attributes[ATTR_WEATHER_UV_INDEX] == 1 + assert state.attributes[ATTR_ATTRIBUTION] == ATTRIBUTION + + +async def test_current_weather_nighttime(hass: HomeAssistant) -> None: + """Test that the condition is clear-night when it's sunny and night time.""" + await init_integration(hass, is_night_time=True) + + state = hass.states.get("weather.home") + assert state + assert state.state == "clear-night" + + +async def test_daily_forecast_missing(hass: HomeAssistant) -> None: + """Test that daily forecast is not supported when WeatherKit doesn't support it.""" + await init_integration(hass, has_daily_forecast=False) + + state = hass.states.get("weather.home") + assert state + assert ( + state.attributes[ATTR_SUPPORTED_FEATURES] & WeatherEntityFeature.FORECAST_DAILY + ) == 0 + + +async def test_hourly_forecast_missing(hass: HomeAssistant) -> None: + """Test that hourly forecast is not supported when WeatherKit doesn't support it.""" + await init_integration(hass, has_hourly_forecast=False) + + state = hass.states.get("weather.home") + assert state + assert ( + state.attributes[ATTR_SUPPORTED_FEATURES] & WeatherEntityFeature.FORECAST_HOURLY + ) == 0 + + +async def test_hourly_forecast( + hass: HomeAssistant, snapshot: SnapshotAssertion +) -> None: + """Test states of the hourly forecast.""" + await init_integration(hass) + + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + { + "entity_id": "weather.home", + "type": "hourly", + }, + blocking=True, + return_response=True, + ) + assert response["forecast"] != [] + assert response == snapshot + + +async def test_daily_forecast(hass: HomeAssistant, snapshot: SnapshotAssertion) -> None: + """Test states of the daily forecast.""" + await init_integration(hass) + + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + { + "entity_id": "weather.home", + "type": "daily", + }, + blocking=True, + return_response=True, + ) + assert response["forecast"] != [] + assert response == snapshot diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index 73baa968ab6c17..f200c44acca635 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -2,6 +2,7 @@ import asyncio from copy import deepcopy import datetime +import logging from unittest.mock import ANY, AsyncMock, Mock, patch import pytest @@ -19,7 +20,7 @@ from homeassistant.const import SIGNAL_BOOTSTRAP_INTEGRATIONS from homeassistant.core import Context, HomeAssistant, State, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import device_registry as dr, entity +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.loader import async_get_integration from homeassistant.setup import DATA_SETUP_TIME, async_setup_component @@ -33,7 +34,11 @@ async_mock_service, mock_platform, ) -from tests.typing import ClientSessionGenerator, WebSocketGenerator +from tests.typing import ( + ClientSessionGenerator, + MockHAClientWebSocket, + WebSocketGenerator, +) STATE_KEY_SHORT_NAMES = { "entity_id": "e", @@ -87,7 +92,9 @@ def _apply_entities_changes(state_dict: dict, change_dict: dict) -> None: del state_dict[STATE_KEY_LONG_NAMES[key]][item] -async def test_fire_event(hass: HomeAssistant, websocket_client) -> None: +async def test_fire_event( + hass: HomeAssistant, websocket_client: MockHAClientWebSocket +) -> None: """Test fire event command.""" runs = [] @@ -116,7 +123,9 @@ async def event_handler(event): assert runs[0].data == {"hello": "world"} -async def test_fire_event_without_data(hass: HomeAssistant, websocket_client) -> None: +async def test_fire_event_without_data( + hass: HomeAssistant, websocket_client: MockHAClientWebSocket +) -> None: """Test fire event command.""" runs = [] @@ -144,7 +153,9 @@ async def event_handler(event): assert runs[0].data == {} -async def test_call_service(hass: HomeAssistant, websocket_client) -> None: +async def test_call_service( + hass: HomeAssistant, websocket_client: MockHAClientWebSocket +) -> None: """Test call service command.""" calls = async_mock_service(hass, "domain_test", "test_service") @@ -174,7 +185,7 @@ async def test_call_service(hass: HomeAssistant, websocket_client) -> None: @pytest.mark.parametrize("command", ("call_service", "call_service_action")) async def test_call_service_blocking( - hass: HomeAssistant, websocket_client, command + hass: HomeAssistant, websocket_client: MockHAClientWebSocket, command ) -> None: """Test call service commands block, except for homeassistant restart / stop.""" with patch( @@ -251,7 +262,9 @@ async def test_call_service_blocking( ) -async def test_call_service_target(hass: HomeAssistant, websocket_client) -> None: +async def test_call_service_target( + hass: HomeAssistant, websocket_client: MockHAClientWebSocket +) -> None: """Test call service command with target.""" calls = async_mock_service(hass, "domain_test", "test_service") @@ -311,7 +324,9 @@ async def test_call_service_target_template( assert msg["error"]["code"] == const.ERR_INVALID_FORMAT -async def test_call_service_not_found(hass: HomeAssistant, websocket_client) -> None: +async def test_call_service_not_found( + hass: HomeAssistant, websocket_client: MockHAClientWebSocket +) -> None: """Test call service command.""" await websocket_client.send_json( { @@ -428,7 +443,9 @@ def service_call(call): assert len(calls) == 0 -async def test_call_service_error(hass: HomeAssistant, websocket_client) -> None: +async def test_call_service_error( + hass: HomeAssistant, websocket_client: MockHAClientWebSocket +) -> None: """Test call service command with error.""" @callback @@ -521,7 +538,9 @@ async def test_subscribe_unsubscribe_events( assert sum(hass.bus.async_listeners().values()) == init_count -async def test_get_states(hass: HomeAssistant, websocket_client) -> None: +async def test_get_states( + hass: HomeAssistant, websocket_client: MockHAClientWebSocket +) -> None: """Test get_states command.""" hass.states.async_set("greeting.hello", "world") hass.states.async_set("greeting.bye", "universe") @@ -540,7 +559,9 @@ async def test_get_states(hass: HomeAssistant, websocket_client) -> None: assert msg["result"] == states -async def test_get_services(hass: HomeAssistant, websocket_client) -> None: +async def test_get_services( + hass: HomeAssistant, websocket_client: MockHAClientWebSocket +) -> None: """Test get_services command.""" for id_ in (5, 6): await websocket_client.send_json({"id": id_, "type": "get_services"}) @@ -552,7 +573,9 @@ async def test_get_services(hass: HomeAssistant, websocket_client) -> None: assert msg["result"] == hass.services.async_services() -async def test_get_config(hass: HomeAssistant, websocket_client) -> None: +async def test_get_config( + hass: HomeAssistant, websocket_client: MockHAClientWebSocket +) -> None: """Test get_config command.""" await websocket_client.send_json({"id": 5, "type": "get_config"}) @@ -579,7 +602,7 @@ async def test_get_config(hass: HomeAssistant, websocket_client) -> None: assert msg["result"] == hass.config.as_dict() -async def test_ping(websocket_client) -> None: +async def test_ping(websocket_client: MockHAClientWebSocket) -> None: """Test get_panels command.""" await websocket_client.send_json({"id": 5, "type": "ping"}) @@ -632,7 +655,7 @@ async def test_call_service_context_with_user( async def test_subscribe_requires_admin( - websocket_client, hass_admin_user: MockUser + websocket_client: MockHAClientWebSocket, hass_admin_user: MockUser ) -> None: """Test subscribing events without being admin.""" hass_admin_user.groups = [] @@ -663,7 +686,9 @@ async def test_states_filters_visible( assert msg["result"][0]["entity_id"] == "test.entity" -async def test_get_states_not_allows_nan(hass: HomeAssistant, websocket_client) -> None: +async def test_get_states_not_allows_nan( + hass: HomeAssistant, websocket_client: MockHAClientWebSocket +) -> None: """Test get_states command converts NaN to None.""" hass.states.async_set("greeting.hello", "world") hass.states.async_set("greeting.bad", "data", {"hello": float("NaN")}) @@ -686,7 +711,9 @@ async def test_get_states_not_allows_nan(hass: HomeAssistant, websocket_client) async def test_subscribe_unsubscribe_events_whitelist( - hass: HomeAssistant, websocket_client, hass_admin_user: MockUser + hass: HomeAssistant, + websocket_client: MockHAClientWebSocket, + hass_admin_user: MockUser, ) -> None: """Test subscribe/unsubscribe events on whitelist.""" hass_admin_user.groups = [] @@ -723,7 +750,9 @@ async def test_subscribe_unsubscribe_events_whitelist( async def test_subscribe_unsubscribe_events_state_changed( - hass: HomeAssistant, websocket_client, hass_admin_user: MockUser + hass: HomeAssistant, + websocket_client: MockHAClientWebSocket, + hass_admin_user: MockUser, ) -> None: """Test subscribe/unsubscribe state_changed events.""" hass_admin_user.groups = [] @@ -749,7 +778,9 @@ async def test_subscribe_unsubscribe_events_state_changed( async def test_subscribe_entities_with_unserializable_state( - hass: HomeAssistant, websocket_client, hass_admin_user: MockUser + hass: HomeAssistant, + websocket_client: MockHAClientWebSocket, + hass_admin_user: MockUser, ) -> None: """Test subscribe entities with an unserializeable state.""" @@ -866,7 +897,9 @@ def __init__(self): async def test_subscribe_unsubscribe_entities( - hass: HomeAssistant, websocket_client, hass_admin_user: MockUser + hass: HomeAssistant, + websocket_client: MockHAClientWebSocket, + hass_admin_user: MockUser, ) -> None: """Test subscribe/unsubscribe entities.""" @@ -1032,7 +1065,9 @@ async def test_subscribe_unsubscribe_entities( async def test_subscribe_unsubscribe_entities_specific_entities( - hass: HomeAssistant, websocket_client, hass_admin_user: MockUser + hass: HomeAssistant, + websocket_client: MockHAClientWebSocket, + hass_admin_user: MockUser, ) -> None: """Test subscribe/unsubscribe entities with a list of entity ids.""" @@ -1225,46 +1260,229 @@ async def test_render_template_manual_entity_ids_no_longer_needed( } +EMPTY_LISTENERS = {"all": False, "entities": [], "domains": [], "time": False} + +ERR_MSG = {"type": "result", "success": False} + +EVENT_UNDEFINED_FUNC_1 = { + "error": "'my_unknown_func' is undefined", + "level": "ERROR", +} +EVENT_UNDEFINED_FUNC_2 = { + "error": "UndefinedError: 'my_unknown_func' is undefined", + "level": "ERROR", +} + +EVENT_UNDEFINED_VAR_WARN = { + "error": "'my_unknown_var' is undefined", + "level": "WARNING", +} +EVENT_UNDEFINED_VAR_ERR = { + "error": "UndefinedError: 'my_unknown_var' is undefined", + "level": "ERROR", +} + +EVENT_UNDEFINED_FILTER = { + "error": "TemplateAssertionError: No filter named 'unknown_filter'.", + "level": "ERROR", +} + + @pytest.mark.parametrize( - "template", + ("template", "expected_events"), [ - "{{ my_unknown_func() + 1 }}", - "{{ my_unknown_var }}", - "{{ my_unknown_var + 1 }}", - "{{ now() | unknown_filter }}", + ( + "{{ my_unknown_func() + 1 }}", + [ + {"type": "event", "event": EVENT_UNDEFINED_FUNC_1}, + {"type": "event", "event": EVENT_UNDEFINED_FUNC_2}, + {"type": "result", "success": True, "result": None}, + {"type": "event", "event": EVENT_UNDEFINED_FUNC_1}, + {"type": "event", "event": EVENT_UNDEFINED_FUNC_2}, + ], + ), + ( + "{{ my_unknown_var }}", + [ + {"type": "event", "event": EVENT_UNDEFINED_VAR_WARN}, + {"type": "result", "success": True, "result": None}, + {"type": "event", "event": EVENT_UNDEFINED_VAR_WARN}, + { + "type": "event", + "event": {"result": "", "listeners": EMPTY_LISTENERS}, + }, + ], + ), + ( + "{{ my_unknown_var + 1 }}", + [ + {"type": "event", "event": EVENT_UNDEFINED_VAR_ERR}, + {"type": "result", "success": True, "result": None}, + {"type": "event", "event": EVENT_UNDEFINED_VAR_ERR}, + ], + ), + ( + "{{ now() | unknown_filter }}", + [ + {"type": "event", "event": EVENT_UNDEFINED_FILTER}, + {"type": "result", "success": True, "result": None}, + {"type": "event", "event": EVENT_UNDEFINED_FILTER}, + ], + ), ], ) async def test_render_template_with_error( - hass: HomeAssistant, websocket_client, caplog: pytest.LogCaptureFixture, template + hass: HomeAssistant, + websocket_client: MockHAClientWebSocket, + caplog: pytest.LogCaptureFixture, + template: str, + expected_events: list[dict[str, str]], ) -> None: """Test a template with an error.""" + caplog.set_level(logging.INFO) await websocket_client.send_json( - {"id": 5, "type": "render_template", "template": template, "strict": True} + { + "id": 5, + "type": "render_template", + "template": template, + "report_errors": True, + } ) - msg = await websocket_client.receive_json() - assert msg["id"] == 5 - assert msg["type"] == const.TYPE_RESULT - assert not msg["success"] - assert msg["error"]["code"] == const.ERR_TEMPLATE_ERROR + for expected_event in expected_events: + msg = await websocket_client.receive_json() + assert msg["id"] == 5 + for key, value in expected_event.items(): + assert msg[key] == value assert "Template variable error" not in caplog.text + assert "Template variable warning" not in caplog.text assert "TemplateError" not in caplog.text @pytest.mark.parametrize( - "template", + ("template", "expected_events"), [ - "{{ my_unknown_func() + 1 }}", - "{{ my_unknown_var }}", - "{{ my_unknown_var + 1 }}", - "{{ now() | unknown_filter }}", + ( + "{{ my_unknown_func() + 1 }}", + [ + {"type": "event", "event": EVENT_UNDEFINED_FUNC_1}, + {"type": "event", "event": EVENT_UNDEFINED_FUNC_2}, + {"type": "result", "success": True, "result": None}, + {"type": "event", "event": EVENT_UNDEFINED_FUNC_1}, + {"type": "event", "event": EVENT_UNDEFINED_FUNC_2}, + {"type": "event", "event": EVENT_UNDEFINED_FUNC_1}, + ], + ), + ( + "{{ my_unknown_var }}", + [ + {"type": "event", "event": EVENT_UNDEFINED_VAR_WARN}, + {"type": "result", "success": True, "result": None}, + {"type": "event", "event": EVENT_UNDEFINED_VAR_WARN}, + { + "type": "event", + "event": {"result": "", "listeners": EMPTY_LISTENERS}, + }, + ], + ), + ( + "{{ my_unknown_var + 1 }}", + [ + {"type": "event", "event": EVENT_UNDEFINED_VAR_ERR}, + {"type": "result", "success": True, "result": None}, + {"type": "event", "event": EVENT_UNDEFINED_VAR_ERR}, + ], + ), + ( + "{{ now() | unknown_filter }}", + [ + {"type": "event", "event": EVENT_UNDEFINED_FILTER}, + {"type": "result", "success": True, "result": None}, + {"type": "event", "event": EVENT_UNDEFINED_FILTER}, + ], + ), ], ) async def test_render_template_with_timeout_and_error( - hass: HomeAssistant, websocket_client, caplog: pytest.LogCaptureFixture, template + hass: HomeAssistant, + websocket_client: MockHAClientWebSocket, + caplog: pytest.LogCaptureFixture, + template: str, + expected_events: list[dict[str, str]], ) -> None: """Test a template with an error with a timeout.""" + caplog.set_level(logging.INFO) + await websocket_client.send_json( + { + "id": 5, + "type": "render_template", + "template": template, + "timeout": 5, + "report_errors": True, + } + ) + + for expected_event in expected_events: + msg = await websocket_client.receive_json() + assert msg["id"] == 5 + for key, value in expected_event.items(): + assert msg[key] == value + + assert "Template variable error" not in caplog.text + assert "Template variable warning" not in caplog.text + assert "TemplateError" not in caplog.text + + +@pytest.mark.parametrize( + ("template", "expected_events"), + [ + ( + "{{ my_unknown_func() + 1 }}", + [ + {"type": "event", "event": EVENT_UNDEFINED_FUNC_2}, + {"type": "result", "success": True, "result": None}, + {"type": "event", "event": EVENT_UNDEFINED_FUNC_2}, + ], + ), + ( + "{{ my_unknown_var }}", + [ + {"type": "event", "event": EVENT_UNDEFINED_VAR_ERR}, + {"type": "result", "success": True, "result": None}, + {"type": "event", "event": EVENT_UNDEFINED_VAR_ERR}, + ], + ), + ( + "{{ my_unknown_var + 1 }}", + [ + {"type": "event", "event": EVENT_UNDEFINED_VAR_ERR}, + {"type": "result", "success": True, "result": None}, + {"type": "event", "event": EVENT_UNDEFINED_VAR_ERR}, + ], + ), + ( + "{{ now() | unknown_filter }}", + [ + {"type": "event", "event": EVENT_UNDEFINED_FILTER}, + {"type": "result", "success": True, "result": None}, + {"type": "event", "event": EVENT_UNDEFINED_FILTER}, + ], + ), + ], +) +async def test_render_template_strict_with_timeout_and_error( + hass: HomeAssistant, + websocket_client, + caplog: pytest.LogCaptureFixture, + template: str, + expected_events: list[dict[str, str]], +) -> None: + """Test a template with an error with a timeout. + + In this test report_errors is enabled. + """ + caplog.set_level(logging.INFO) await websocket_client.send_json( { "id": 5, @@ -1272,40 +1490,246 @@ async def test_render_template_with_timeout_and_error( "template": template, "timeout": 5, "strict": True, + "report_errors": True, } ) - msg = await websocket_client.receive_json() - assert msg["id"] == 5 - assert msg["type"] == const.TYPE_RESULT - assert not msg["success"] - assert msg["error"]["code"] == const.ERR_TEMPLATE_ERROR + for expected_event in expected_events: + msg = await websocket_client.receive_json() + assert msg["id"] == 5 + for key, value in expected_event.items(): + assert msg[key] == value assert "Template variable error" not in caplog.text + assert "Template variable warning" not in caplog.text assert "TemplateError" not in caplog.text +@pytest.mark.parametrize( + ("template", "expected_events"), + [ + ( + "{{ my_unknown_func() + 1 }}", + [ + {"type": "result", "success": True, "result": None}, + ], + ), + ( + "{{ my_unknown_var }}", + [ + {"type": "result", "success": True, "result": None}, + ], + ), + ( + "{{ my_unknown_var + 1 }}", + [ + {"type": "result", "success": True, "result": None}, + ], + ), + ( + "{{ now() | unknown_filter }}", + [ + {"type": "result", "success": True, "result": None}, + ], + ), + ], +) +async def test_render_template_strict_with_timeout_and_error_2( + hass: HomeAssistant, + websocket_client, + caplog: pytest.LogCaptureFixture, + template: str, + expected_events: list[dict[str, str]], +) -> None: + """Test a template with an error with a timeout. + + In this test report_errors is disabled. + """ + caplog.set_level(logging.INFO) + await websocket_client.send_json( + { + "id": 5, + "type": "render_template", + "template": template, + "timeout": 5, + "strict": True, + } + ) + + for expected_event in expected_events: + msg = await websocket_client.receive_json() + assert msg["id"] == 5 + for key, value in expected_event.items(): + assert msg[key] == value + + assert "TemplateError" in caplog.text + + +@pytest.mark.parametrize( + ("template", "expected_events_1", "expected_events_2"), + [ + ( + "{{ now() | random }}", + [ + { + "type": "event", + "event": { + "error": "TypeError: object of type 'datetime.datetime' has no len()", + "level": "ERROR", + }, + }, + {"type": "result", "success": True, "result": None}, + { + "type": "event", + "event": { + "error": "TypeError: object of type 'datetime.datetime' has no len()", + "level": "ERROR", + }, + }, + ], + [], + ), + ( + "{{ float(states.sensor.foo.state) + 1 }}", + [ + { + "type": "event", + "event": { + "error": "UndefinedError: 'None' has no attribute 'state'", + "level": "ERROR", + }, + }, + {"type": "result", "success": True, "result": None}, + { + "type": "event", + "event": { + "error": "UndefinedError: 'None' has no attribute 'state'", + "level": "ERROR", + }, + }, + ], + [ + { + "type": "event", + "event": { + "result": 3.0, + "listeners": EMPTY_LISTENERS | {"entities": ["sensor.foo"]}, + }, + }, + ], + ), + ], +) async def test_render_template_error_in_template_code( - hass: HomeAssistant, websocket_client, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + websocket_client: MockHAClientWebSocket, + caplog: pytest.LogCaptureFixture, + template: str, + expected_events_1: list[dict[str, str]], + expected_events_2: list[dict[str, str]], ) -> None: - """Test a template that will throw in template.py.""" + """Test a template that will throw in template.py. + + In this test report_errors is enabled. + """ await websocket_client.send_json( - {"id": 5, "type": "render_template", "template": "{{ now() | random }}"} + { + "id": 5, + "type": "render_template", + "template": template, + "report_errors": True, + } ) - msg = await websocket_client.receive_json() - assert msg["id"] == 5 - assert msg["type"] == const.TYPE_RESULT - assert not msg["success"] - assert msg["error"]["code"] == const.ERR_TEMPLATE_ERROR + for expected_event in expected_events_1: + msg = await websocket_client.receive_json() + assert msg["id"] == 5 + for key, value in expected_event.items(): + assert msg[key] == value + + hass.states.async_set("sensor.foo", "2") + + for expected_event in expected_events_2: + msg = await websocket_client.receive_json() + assert msg["id"] == 5 + for key, value in expected_event.items(): + assert msg[key] == value + assert "Template variable error" not in caplog.text + assert "Template variable warning" not in caplog.text assert "TemplateError" not in caplog.text +@pytest.mark.parametrize( + ("template", "expected_events_1", "expected_events_2"), + [ + ( + "{{ now() | random }}", + [ + {"type": "result", "success": True, "result": None}, + ], + [], + ), + ( + "{{ float(states.sensor.foo.state) + 1 }}", + [ + {"type": "result", "success": True, "result": None}, + ], + [ + { + "type": "event", + "event": { + "result": 3.0, + "listeners": EMPTY_LISTENERS | {"entities": ["sensor.foo"]}, + }, + }, + ], + ), + ], +) +async def test_render_template_error_in_template_code_2( + hass: HomeAssistant, + websocket_client, + caplog: pytest.LogCaptureFixture, + template: str, + expected_events_1: list[dict[str, str]], + expected_events_2: list[dict[str, str]], +) -> None: + """Test a template that will throw in template.py. + + In this test report_errors is disabled. + """ + await websocket_client.send_json( + {"id": 5, "type": "render_template", "template": template} + ) + + for expected_event in expected_events_1: + msg = await websocket_client.receive_json() + assert msg["id"] == 5 + for key, value in expected_event.items(): + assert msg[key] == value + + hass.states.async_set("sensor.foo", "2") + + for expected_event in expected_events_2: + msg = await websocket_client.receive_json() + assert msg["id"] == 5 + for key, value in expected_event.items(): + assert msg[key] == value + + assert "TemplateError" in caplog.text + + async def test_render_template_with_delayed_error( - hass: HomeAssistant, websocket_client, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + websocket_client: MockHAClientWebSocket, + caplog: pytest.LogCaptureFixture, ) -> None: - """Test a template with an error that only happens after a state change.""" + """Test a template with an error that only happens after a state change. + + In this test report_errors is enabled. + """ + caplog.set_level(logging.INFO) hass.states.async_set("sensor.test", "on") await hass.async_block_till_done() @@ -1318,12 +1742,16 @@ async def test_render_template_with_delayed_error( """ await websocket_client.send_json( - {"id": 5, "type": "render_template", "template": template_str} + { + "id": 5, + "type": "render_template", + "template": template_str, + "report_errors": True, + } ) await hass.async_block_till_done() msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -1347,15 +1775,81 @@ async def test_render_template_with_delayed_error( msg = await websocket_client.receive_json() assert msg["id"] == 5 - assert msg["type"] == const.TYPE_RESULT - assert not msg["success"] - assert msg["error"]["code"] == const.ERR_TEMPLATE_ERROR + assert msg["type"] == "event" + event = msg["event"] + assert event["error"] == "'None' has no attribute 'state'" + msg = await websocket_client.receive_json() + assert msg["id"] == 5 + assert msg["type"] == "event" + event = msg["event"] + assert event == { + "error": "UndefinedError: 'explode' is undefined", + "level": "ERROR", + } + + assert "Template variable error" not in caplog.text + assert "Template variable warning" not in caplog.text assert "TemplateError" not in caplog.text -async def test_render_template_with_timeout( +async def test_render_template_with_delayed_error_2( hass: HomeAssistant, websocket_client, caplog: pytest.LogCaptureFixture +) -> None: + """Test a template with an error that only happens after a state change. + + In this test report_errors is disabled. + """ + hass.states.async_set("sensor.test", "on") + await hass.async_block_till_done() + + template_str = """ +{% if states.sensor.test.state %} + on +{% else %} + {{ explode + 1 }} +{% endif %} + """ + + await websocket_client.send_json( + { + "id": 5, + "type": "render_template", + "template": template_str, + "report_errors": False, + } + ) + await hass.async_block_till_done() + + msg = await websocket_client.receive_json() + assert msg["id"] == 5 + assert msg["type"] == const.TYPE_RESULT + assert msg["success"] + + hass.states.async_remove("sensor.test") + await hass.async_block_till_done() + + msg = await websocket_client.receive_json() + assert msg["id"] == 5 + assert msg["type"] == "event" + event = msg["event"] + assert event == { + "result": "on", + "listeners": { + "all": False, + "domains": [], + "entities": ["sensor.test"], + "time": False, + }, + } + + assert "Template variable warning" in caplog.text + + +async def test_render_template_with_timeout( + hass: HomeAssistant, + websocket_client: MockHAClientWebSocket, + caplog: pytest.LogCaptureFixture, ) -> None: """Test a template that will timeout.""" @@ -1399,7 +1893,9 @@ async def test_render_template_returns_with_match_all( assert msg["success"] -async def test_manifest_list(hass: HomeAssistant, websocket_client) -> None: +async def test_manifest_list( + hass: HomeAssistant, websocket_client: MockHAClientWebSocket +) -> None: """Test loading manifests.""" http = await async_get_integration(hass, "http") websocket_api = await async_get_integration(hass, "websocket_api") @@ -1437,7 +1933,9 @@ async def test_manifest_list_specific_integrations( ] -async def test_manifest_get(hass: HomeAssistant, websocket_client) -> None: +async def test_manifest_get( + hass: HomeAssistant, websocket_client: MockHAClientWebSocket +) -> None: """Test getting a manifest.""" hue = await async_get_integration(hass, "hue") @@ -1464,7 +1962,9 @@ async def test_manifest_get(hass: HomeAssistant, websocket_client) -> None: async def test_entity_source_admin( - hass: HomeAssistant, websocket_client, hass_admin_user: MockUser + hass: HomeAssistant, + websocket_client: MockHAClientWebSocket, + hass_admin_user: MockUser, ) -> None: """Check that we fetch sources correctly.""" platform = MockEntityPlatform(hass) @@ -1481,76 +1981,10 @@ async def test_entity_source_admin( assert msg["type"] == const.TYPE_RESULT assert msg["success"] assert msg["result"] == { - "test_domain.entity_1": { - "custom_component": False, - "domain": "test_platform", - "source": entity.SOURCE_PLATFORM_CONFIG, - }, - "test_domain.entity_2": { - "custom_component": False, - "domain": "test_platform", - "source": entity.SOURCE_PLATFORM_CONFIG, - }, - } - - # Fetch one - await websocket_client.send_json( - {"id": 7, "type": "entity/source", "entity_id": ["test_domain.entity_2"]} - ) - - msg = await websocket_client.receive_json() - assert msg["id"] == 7 - assert msg["type"] == const.TYPE_RESULT - assert msg["success"] - assert msg["result"] == { - "test_domain.entity_2": { - "custom_component": False, - "domain": "test_platform", - "source": entity.SOURCE_PLATFORM_CONFIG, - }, - } - - # Fetch two - await websocket_client.send_json( - { - "id": 8, - "type": "entity/source", - "entity_id": ["test_domain.entity_2", "test_domain.entity_1"], - } - ) - - msg = await websocket_client.receive_json() - assert msg["id"] == 8 - assert msg["type"] == const.TYPE_RESULT - assert msg["success"] - assert msg["result"] == { - "test_domain.entity_1": { - "custom_component": False, - "domain": "test_platform", - "source": entity.SOURCE_PLATFORM_CONFIG, - }, - "test_domain.entity_2": { - "custom_component": False, - "domain": "test_platform", - "source": entity.SOURCE_PLATFORM_CONFIG, - }, + "test_domain.entity_1": {"domain": "test_platform"}, + "test_domain.entity_2": {"domain": "test_platform"}, } - # Fetch non existing - await websocket_client.send_json( - { - "id": 9, - "type": "entity/source", - "entity_id": ["test_domain.entity_2", "test_domain.non_existing"], - } - ) - - msg = await websocket_client.receive_json() - assert msg["id"] == 9 - assert msg["type"] == const.TYPE_RESULT - assert not msg["success"] - assert msg["error"]["code"] == const.ERR_NOT_FOUND - # Mock policy hass_admin_user.groups = [] hass_admin_user.mock_policy( @@ -1565,26 +1999,13 @@ async def test_entity_source_admin( assert msg["type"] == const.TYPE_RESULT assert msg["success"] assert msg["result"] == { - "test_domain.entity_2": { - "custom_component": False, - "domain": "test_platform", - "source": entity.SOURCE_PLATFORM_CONFIG, - }, + "test_domain.entity_2": {"domain": "test_platform"}, } - # Fetch unauthorized - await websocket_client.send_json( - {"id": 11, "type": "entity/source", "entity_id": ["test_domain.entity_1"]} - ) - - msg = await websocket_client.receive_json() - assert msg["id"] == 11 - assert msg["type"] == const.TYPE_RESULT - assert not msg["success"] - assert msg["error"]["code"] == const.ERR_UNAUTHORIZED - -async def test_subscribe_trigger(hass: HomeAssistant, websocket_client) -> None: +async def test_subscribe_trigger( + hass: HomeAssistant, websocket_client: MockHAClientWebSocket +) -> None: """Test subscribing to a trigger.""" init_count = sum(hass.bus.async_listeners().values()) @@ -1638,7 +2059,9 @@ async def test_subscribe_trigger(hass: HomeAssistant, websocket_client) -> None: assert sum(hass.bus.async_listeners().values()) == init_count -async def test_test_condition(hass: HomeAssistant, websocket_client) -> None: +async def test_test_condition( + hass: HomeAssistant, websocket_client: MockHAClientWebSocket +) -> None: """Test testing a condition.""" hass.states.async_set("hello.world", "paulus") @@ -1698,7 +2121,9 @@ async def test_test_condition(hass: HomeAssistant, websocket_client) -> None: assert msg["result"]["result"] is False -async def test_execute_script(hass: HomeAssistant, websocket_client) -> None: +async def test_execute_script( + hass: HomeAssistant, websocket_client: MockHAClientWebSocket +) -> None: """Test testing a condition.""" calls = async_mock_service( hass, "domain_test", "test_service", response={"hello": "world"} @@ -1847,7 +2272,9 @@ async def test_execute_script_with_dynamically_validated_action( async def test_subscribe_unsubscribe_bootstrap_integrations( - hass: HomeAssistant, websocket_client, hass_admin_user: MockUser + hass: HomeAssistant, + websocket_client: MockHAClientWebSocket, + hass_admin_user: MockUser, ) -> None: """Test subscribe/unsubscribe bootstrap_integrations.""" await websocket_client.send_json( @@ -1869,7 +2296,9 @@ async def test_subscribe_unsubscribe_bootstrap_integrations( async def test_integration_setup_info( - hass: HomeAssistant, websocket_client, hass_admin_user: MockUser + hass: HomeAssistant, + websocket_client: MockHAClientWebSocket, + hass_admin_user: MockUser, ) -> None: """Test subscribe/unsubscribe bootstrap_integrations.""" hass.data[DATA_SETUP_TIME] = { @@ -1905,7 +2334,9 @@ async def test_integration_setup_info( ("action", [{"service": "domain_test.test_service"}]), ), ) -async def test_validate_config_works(websocket_client, key, config) -> None: +async def test_validate_config_works( + websocket_client: MockHAClientWebSocket, key, config +) -> None: """Test config validation.""" await websocket_client.send_json({"id": 7, "type": "validate_config", key: config}) @@ -1944,7 +2375,9 @@ async def test_validate_config_works(websocket_client, key, config) -> None: ), ), ) -async def test_validate_config_invalid(websocket_client, key, config, error) -> None: +async def test_validate_config_invalid( + websocket_client: MockHAClientWebSocket, key, config, error +) -> None: """Test config validation.""" await websocket_client.send_json({"id": 7, "type": "validate_config", key: config}) @@ -1956,7 +2389,9 @@ async def test_validate_config_invalid(websocket_client, key, config, error) -> async def test_message_coalescing( - hass: HomeAssistant, websocket_client, hass_admin_user: MockUser + hass: HomeAssistant, + websocket_client: MockHAClientWebSocket, + hass_admin_user: MockUser, ) -> None: """Test enabling message coalescing.""" await websocket_client.send_json( @@ -2028,7 +2463,9 @@ async def test_message_coalescing( async def test_message_coalescing_not_supported_by_websocket_client( - hass: HomeAssistant, websocket_client, hass_admin_user: MockUser + hass: HomeAssistant, + websocket_client: MockHAClientWebSocket, + hass_admin_user: MockUser, ) -> None: """Test enabling message coalescing not supported by websocket client.""" await websocket_client.send_json({"id": 7, "type": "subscribe_entities"}) @@ -2070,7 +2507,9 @@ async def test_message_coalescing_not_supported_by_websocket_client( async def test_client_message_coalescing( - hass: HomeAssistant, websocket_client, hass_admin_user: MockUser + hass: HomeAssistant, + websocket_client: MockHAClientWebSocket, + hass_admin_user: MockUser, ) -> None: """Test client message coalescing.""" await websocket_client.send_json( diff --git a/tests/components/withings/__init__.py b/tests/components/withings/__init__.py index c1caac222a5d93..4634a77a8daf6f 100644 --- a/tests/components/withings/__init__.py +++ b/tests/components/withings/__init__.py @@ -1 +1,65 @@ """Tests for the withings component.""" +from dataclasses import dataclass +from typing import Any +from urllib.parse import urlparse + +from homeassistant.components.webhook import async_generate_url +from homeassistant.components.withings.const import CONF_USE_WEBHOOK, DOMAIN +from homeassistant.config import async_process_ha_core_config +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + + +@dataclass +class WebhookResponse: + """Response data from a webhook.""" + + message: str + message_code: int + + +async def call_webhook( + hass: HomeAssistant, webhook_id: str, data: dict[str, Any], client +) -> WebhookResponse: + """Call the webhook.""" + webhook_url = async_generate_url(hass, webhook_id) + + resp = await client.post( + urlparse(webhook_url).path, + data=data, + ) + + # Wait for remaining tasks to complete. + await hass.async_block_till_done() + + data: dict[str, Any] = await resp.json() + resp.close() + + return WebhookResponse(message=data["message"], message_code=data["code"]) + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await async_process_ha_core_config( + hass, + {"internal_url": "http://example.local:8123"}, + ) + + await hass.config_entries.async_setup(config_entry.entry_id) + + +async def enable_webhooks(hass: HomeAssistant) -> None: + """Enable webhooks.""" + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + CONF_USE_WEBHOOK: True, + } + }, + ) diff --git a/tests/components/withings/common.py b/tests/components/withings/common.py index e5c246dc95e536..7680b19e28901e 100644 --- a/tests/components/withings/common.py +++ b/tests/components/withings/common.py @@ -23,11 +23,10 @@ from homeassistant.components.withings.common import ( ConfigEntryWithingsApi, DataManager, - WithingsEntityDescription, get_all_data_managers, - get_attribute_unique_id, ) import homeassistant.components.withings.const as const +from homeassistant.components.withings.entity import WithingsEntityDescription from homeassistant.config import async_process_ha_core_config from homeassistant.config_entries import SOURCE_USER, ConfigEntry from homeassistant.const import ( @@ -44,6 +43,7 @@ from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry +from tests.components.withings import WebhookResponse from tests.test_util.aiohttp import AiohttpClientMocker @@ -91,14 +91,6 @@ def new_profile_config( ) -@dataclass -class WebhookResponse: - """Response data from a webhook.""" - - message: str - message_code: int - - class ComponentFactory: """Manages the setup and unloading of the withing component and profiles.""" @@ -331,6 +323,6 @@ async def async_get_entity_id( ) -> str | None: """Get an entity id for a user's attribute.""" entity_registry = er.async_get(hass) - unique_id = get_attribute_unique_id(description, user_id) + unique_id = f"withings_{user_id}_{description.measurement.value}" return entity_registry.async_get_entity_id(platform, const.DOMAIN, unique_id) diff --git a/tests/components/withings/conftest.py b/tests/components/withings/conftest.py index 887a9b8179b238..f1df0e3a65a336 100644 --- a/tests/components/withings/conftest.py +++ b/tests/components/withings/conftest.py @@ -1,15 +1,42 @@ """Fixtures for tests.""" - -from unittest.mock import patch +from datetime import timedelta +import time +from unittest.mock import AsyncMock, patch import pytest +from withings_api import ( + MeasureGetMeasResponse, + NotifyListResponse, + SleepGetSummaryResponse, + UserGetDeviceResponse, +) +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.withings.api import ConfigEntryWithingsApi +from homeassistant.components.withings.const import DOMAIN from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component from .common import ComponentFactory +from tests.common import MockConfigEntry, load_json_object_fixture from tests.test_util.aiohttp import AiohttpClientMocker +CLIENT_ID = "1234" +CLIENT_SECRET = "5678" +SCOPES = [ + "user.info", + "user.metrics", + "user.activity", + "user.sleepevents", +] +TITLE = "henk" +USER_ID = 12345 +WEBHOOK_ID = "55a7335ea8dee830eed4ef8f84cda8f6d80b83af0847dc74032e86120bffed5e" + @pytest.fixture def component_factory( @@ -25,3 +52,92 @@ def component_factory( yield ComponentFactory( hass, api_class_mock, hass_client_no_auth, aioclient_mock ) + + +@pytest.fixture(name="scopes") +def mock_scopes() -> list[str]: + """Fixture to set the scopes present in the OAuth token.""" + return SCOPES + + +@pytest.fixture(autouse=True) +async def setup_credentials(hass: HomeAssistant) -> None: + """Fixture to setup credentials.""" + assert await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), + DOMAIN, + ) + + +@pytest.fixture(name="expires_at") +def mock_expires_at() -> int: + """Fixture to set the oauth token expiration time.""" + return time.time() + 3600 + + +@pytest.fixture(name="config_entry") +def mock_config_entry(expires_at: int, scopes: list[str]) -> MockConfigEntry: + """Create Withings entry in Home Assistant.""" + return MockConfigEntry( + domain=DOMAIN, + title=TITLE, + unique_id=str(USER_ID), + data={ + "auth_implementation": DOMAIN, + "token": { + "status": 0, + "userid": str(USER_ID), + "access_token": "mock-access-token", + "refresh_token": "mock-refresh-token", + "expires_at": expires_at, + "scope": ",".join(scopes), + }, + "profile": TITLE, + "webhook_id": WEBHOOK_ID, + }, + options={ + "use_webhook": True, + }, + ) + + +@pytest.fixture(name="withings") +def mock_withings(): + """Mock withings.""" + + mock = AsyncMock(spec=ConfigEntryWithingsApi) + mock.user_get_device.return_value = UserGetDeviceResponse( + **load_json_object_fixture("withings/get_device.json") + ) + mock.async_measure_get_meas.return_value = MeasureGetMeasResponse( + **load_json_object_fixture("withings/get_meas.json") + ) + mock.async_sleep_get_summary.return_value = SleepGetSummaryResponse( + **load_json_object_fixture("withings/get_sleep.json") + ) + mock.async_notify_list.return_value = NotifyListResponse( + **load_json_object_fixture("withings/notify_list.json") + ) + + with patch( + "homeassistant.components.withings.common.ConfigEntryWithingsApi", + return_value=mock, + ): + yield mock + + +@pytest.fixture(name="disable_webhook_delay") +def disable_webhook_delay(): + """Disable webhook delay.""" + + mock = AsyncMock() + with patch( + "homeassistant.components.withings.common.SUBSCRIBE_DELAY", timedelta(seconds=0) + ), patch( + "homeassistant.components.withings.common.UNSUBSCRIBE_DELAY", + timedelta(seconds=0), + ): + yield mock diff --git a/tests/components/withings/fixtures/get_device.json b/tests/components/withings/fixtures/get_device.json new file mode 100644 index 00000000000000..64bac3d4a190d5 --- /dev/null +++ b/tests/components/withings/fixtures/get_device.json @@ -0,0 +1,15 @@ +{ + "devices": [ + { + "type": "Scale", + "battery": "high", + "model": "Body+", + "model_id": 5, + "timezone": "Europe/Amsterdam", + "first_session_date": null, + "last_session_date": 1693867179, + "deviceid": "f998be4b9ccc9e136fd8cd8e8e344c31ec3b271d", + "hash_deviceid": "f998be4b9ccc9e136fd8cd8e8e344c31ec3b271d" + } + ] +} diff --git a/tests/components/withings/fixtures/get_meas.json b/tests/components/withings/fixtures/get_meas.json new file mode 100644 index 00000000000000..a7a2c09156cbea --- /dev/null +++ b/tests/components/withings/fixtures/get_meas.json @@ -0,0 +1,278 @@ +{ + "more": false, + "timezone": "UTC", + "updatetime": 1564617600, + "offset": 0, + "measuregrps": [ + { + "attrib": 0, + "category": 1, + "created": 1564660800, + "date": 1564660800, + "deviceid": "DEV_ID", + "grpid": 1, + "measures": [ + { + "type": 1, + "unit": 0, + "value": 70 + }, + { + "type": 8, + "unit": 0, + "value": 5 + }, + { + "type": 5, + "unit": 0, + "value": 60 + }, + { + "type": 76, + "unit": 0, + "value": 50 + }, + { + "type": 88, + "unit": 0, + "value": 10 + }, + { + "type": 4, + "unit": 0, + "value": 2 + }, + { + "type": 12, + "unit": 0, + "value": 40 + }, + { + "type": 71, + "unit": 0, + "value": 40 + }, + { + "type": 73, + "unit": 0, + "value": 20 + }, + { + "type": 6, + "unit": -3, + "value": 70 + }, + { + "type": 9, + "unit": 0, + "value": 70 + }, + { + "type": 10, + "unit": 0, + "value": 100 + }, + { + "type": 11, + "unit": 0, + "value": 60 + }, + { + "type": 54, + "unit": -2, + "value": 95 + }, + { + "type": 77, + "unit": -2, + "value": 95 + }, + { + "type": 91, + "unit": 0, + "value": 100 + } + ] + }, + { + "attrib": 0, + "category": 1, + "created": 1564657200, + "date": 1564657200, + "deviceid": "DEV_ID", + "grpid": 1, + "measures": [ + { + "type": 1, + "unit": 0, + "value": 71 + }, + { + "type": 8, + "unit": 0, + "value": 51 + }, + { + "type": 5, + "unit": 0, + "value": 61 + }, + { + "type": 76, + "unit": 0, + "value": 51 + }, + { + "type": 88, + "unit": 0, + "value": 11 + }, + { + "type": 4, + "unit": 0, + "value": 21 + }, + { + "type": 12, + "unit": 0, + "value": 41 + }, + { + "type": 71, + "unit": 0, + "value": 41 + }, + { + "type": 73, + "unit": 0, + "value": 21 + }, + { + "type": 6, + "unit": -3, + "value": 71 + }, + { + "type": 9, + "unit": 0, + "value": 71 + }, + { + "type": 10, + "unit": 0, + "value": 101 + }, + { + "type": 11, + "unit": 0, + "value": 61 + }, + { + "type": 54, + "unit": -2, + "value": 96 + }, + { + "type": 77, + "unit": -2, + "value": 96 + }, + { + "type": 91, + "unit": 0, + "value": 101 + } + ] + }, + { + "attrib": 1, + "category": 1, + "created": 1564664400, + "date": 1564664400, + "deviceid": "DEV_ID", + "grpid": 1, + "measures": [ + { + "type": 1, + "unit": 0, + "value": 71 + }, + { + "type": 8, + "unit": 0, + "value": 4 + }, + { + "type": 5, + "unit": 0, + "value": 40 + }, + { + "type": 76, + "unit": 0, + "value": 51 + }, + { + "type": 88, + "unit": 0, + "value": 11 + }, + { + "type": 4, + "unit": 0, + "value": 201 + }, + { + "type": 12, + "unit": 0, + "value": 41 + }, + { + "type": 71, + "unit": 0, + "value": 34 + }, + { + "type": 73, + "unit": 0, + "value": 21 + }, + { + "type": 6, + "unit": -3, + "value": 71 + }, + { + "type": 9, + "unit": 0, + "value": 71 + }, + { + "type": 10, + "unit": 0, + "value": 101 + }, + { + "type": 11, + "unit": 0, + "value": 61 + }, + { + "type": 54, + "unit": -2, + "value": 98 + }, + { + "type": 77, + "unit": -2, + "value": 96 + }, + { + "type": 91, + "unit": 0, + "value": 102 + } + ] + } + ] +} diff --git a/tests/components/withings/fixtures/get_sleep.json b/tests/components/withings/fixtures/get_sleep.json new file mode 100644 index 00000000000000..fdc0e06470941b --- /dev/null +++ b/tests/components/withings/fixtures/get_sleep.json @@ -0,0 +1,60 @@ +{ + "more": false, + "offset": 0, + "series": [ + { + "timezone": "UTC", + "model": 32, + "startdate": 1548979200, + "enddate": 1548979200, + "date": 1548979200, + "modified": 12345, + "data": { + "breathing_disturbances_intensity": 110, + "deepsleepduration": 111, + "durationtosleep": 112, + "durationtowakeup": 113, + "hr_average": 114, + "hr_max": 115, + "hr_min": 116, + "lightsleepduration": 117, + "remsleepduration": 118, + "rr_average": 119, + "rr_max": 120, + "rr_min": 121, + "sleep_score": 122, + "snoring": 123, + "snoringepisodecount": 124, + "wakeupcount": 125, + "wakeupduration": 126 + } + }, + { + "timezone": "UTC", + "model": 32, + "startdate": 1548979200, + "enddate": 1548979200, + "date": 1548979200, + "modified": 12345, + "data": { + "breathing_disturbances_intensity": 210, + "deepsleepduration": 211, + "durationtosleep": 212, + "durationtowakeup": 213, + "hr_average": 214, + "hr_max": 215, + "hr_min": 216, + "lightsleepduration": 217, + "remsleepduration": 218, + "rr_average": 219, + "rr_max": 220, + "rr_min": 221, + "sleep_score": 222, + "snoring": 223, + "snoringepisodecount": 224, + "wakeupcount": 225, + "wakeupduration": 226 + } + } + ] +} diff --git a/tests/components/withings/fixtures/notify_list.json b/tests/components/withings/fixtures/notify_list.json new file mode 100644 index 00000000000000..bc696db583a950 --- /dev/null +++ b/tests/components/withings/fixtures/notify_list.json @@ -0,0 +1,22 @@ +{ + "profiles": [ + { + "appli": 50, + "callbackurl": "https://not.my.callback/url", + "expires": 2147483647, + "comment": null + }, + { + "appli": 50, + "callbackurl": "http://example.local:8123/api/webhook/55a7335ea8dee830eed4ef8f84cda8f6d80b83af0847dc74032e86120bffed5e", + "expires": 2147483647, + "comment": null + }, + { + "appli": 51, + "callbackurl": "http://example.local:8123/api/webhook/55a7335ea8dee830eed4ef8f84cda8f6d80b83af0847dc74032e86120bffed5e", + "expires": 2147483647, + "comment": null + } + ] +} diff --git a/tests/components/withings/snapshots/test_sensor.ambr b/tests/components/withings/snapshots/test_sensor.ambr new file mode 100644 index 00000000000000..6aa9e5b3784e66 --- /dev/null +++ b/tests/components/withings/snapshots/test_sensor.ambr @@ -0,0 +1,1254 @@ +# serializer version: 1 +# name: test_all_entities + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'weight', + 'friendly_name': 'henk Weight', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_weight', + 'last_changed': , + 'last_updated': , + 'state': '70.0', + }) +# --- +# name: test_all_entities.1 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'weight', + 'friendly_name': 'henk Fat mass', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_fat_mass', + 'last_changed': , + 'last_updated': , + 'state': '5.0', + }) +# --- +# name: test_all_entities.10 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Diastolic blood pressure', + 'state_class': , + 'unit_of_measurement': 'mmhg', + }), + 'context': , + 'entity_id': 'sensor.henk_diastolic_blood_pressure', + 'last_changed': , + 'last_updated': , + 'state': '70.0', + }) +# --- +# name: test_all_entities.11 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Systolic blood pressure', + 'state_class': , + 'unit_of_measurement': 'mmhg', + }), + 'context': , + 'entity_id': 'sensor.henk_systolic_blood_pressure', + 'last_changed': , + 'last_updated': , + 'state': '100.0', + }) +# --- +# name: test_all_entities.12 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Heart pulse', + 'icon': 'mdi:heart-pulse', + 'state_class': , + 'unit_of_measurement': 'bpm', + }), + 'context': , + 'entity_id': 'sensor.henk_heart_pulse', + 'last_changed': , + 'last_updated': , + 'state': '60.0', + }) +# --- +# name: test_all_entities.13 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk SpO2', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.henk_spo2', + 'last_changed': , + 'last_updated': , + 'state': '0.95', + }) +# --- +# name: test_all_entities.14 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'weight', + 'friendly_name': 'henk Hydration', + 'icon': 'mdi:water', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_hydration', + 'last_changed': , + 'last_updated': , + 'state': '0.95', + }) +# --- +# name: test_all_entities.15 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speed', + 'friendly_name': 'henk Pulse wave velocity', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_pulse_wave_velocity', + 'last_changed': , + 'last_updated': , + 'state': '100.0', + }) +# --- +# name: test_all_entities.16 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Breathing disturbances intensity', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.henk_breathing_disturbances_intensity', + 'last_changed': , + 'last_updated': , + 'state': '160.0', + }) +# --- +# name: test_all_entities.17 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'henk Deep sleep', + 'icon': 'mdi:sleep', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_deep_sleep', + 'last_changed': , + 'last_updated': , + 'state': '322', + }) +# --- +# name: test_all_entities.18 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'henk Time to sleep', + 'icon': 'mdi:sleep', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_time_to_sleep', + 'last_changed': , + 'last_updated': , + 'state': '162.0', + }) +# --- +# name: test_all_entities.19 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'henk Time to wakeup', + 'icon': 'mdi:sleep-off', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_time_to_wakeup', + 'last_changed': , + 'last_updated': , + 'state': '163.0', + }) +# --- +# name: test_all_entities.2 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'weight', + 'friendly_name': 'henk Fat free mass', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_fat_free_mass', + 'last_changed': , + 'last_updated': , + 'state': '60.0', + }) +# --- +# name: test_all_entities.20 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Average heart rate', + 'icon': 'mdi:heart-pulse', + 'state_class': , + 'unit_of_measurement': 'bpm', + }), + 'context': , + 'entity_id': 'sensor.henk_average_heart_rate', + 'last_changed': , + 'last_updated': , + 'state': '164.0', + }) +# --- +# name: test_all_entities.21 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Fat mass', + 'icon': 'mdi:heart-pulse', + 'state_class': , + 'unit_of_measurement': 'bpm', + }), + 'context': , + 'entity_id': 'sensor.henk_fat_mass_2', + 'last_changed': , + 'last_updated': , + 'state': '165.0', + }) +# --- +# name: test_all_entities.22 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Maximum heart rate', + 'icon': 'mdi:heart-pulse', + 'state_class': , + 'unit_of_measurement': 'bpm', + }), + 'context': , + 'entity_id': 'sensor.henk_maximum_heart_rate', + 'last_changed': , + 'last_updated': , + 'state': '166.0', + }) +# --- +# name: test_all_entities.23 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'henk Light sleep', + 'icon': 'mdi:sleep', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_light_sleep', + 'last_changed': , + 'last_updated': , + 'state': '334', + }) +# --- +# name: test_all_entities.24 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'henk REM sleep', + 'icon': 'mdi:sleep', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_rem_sleep', + 'last_changed': , + 'last_updated': , + 'state': '336', + }) +# --- +# name: test_all_entities.25 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Average respiratory rate', + 'state_class': , + 'unit_of_measurement': 'br/min', + }), + 'context': , + 'entity_id': 'sensor.henk_average_respiratory_rate', + 'last_changed': , + 'last_updated': , + 'state': '169.0', + }) +# --- +# name: test_all_entities.26 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Maximum respiratory rate', + 'state_class': , + 'unit_of_measurement': 'br/min', + }), + 'context': , + 'entity_id': 'sensor.henk_maximum_respiratory_rate', + 'last_changed': , + 'last_updated': , + 'state': '170.0', + }) +# --- +# name: test_all_entities.27 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Minimum respiratory rate', + 'state_class': , + 'unit_of_measurement': 'br/min', + }), + 'context': , + 'entity_id': 'sensor.henk_minimum_respiratory_rate', + 'last_changed': , + 'last_updated': , + 'state': '171.0', + }) +# --- +# name: test_all_entities.28 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Sleep score', + 'icon': 'mdi:medal', + 'state_class': , + 'unit_of_measurement': 'points', + }), + 'context': , + 'entity_id': 'sensor.henk_sleep_score', + 'last_changed': , + 'last_updated': , + 'state': '222', + }) +# --- +# name: test_all_entities.29 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Snoring', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.henk_snoring', + 'last_changed': , + 'last_updated': , + 'state': '173.0', + }) +# --- +# name: test_all_entities.3 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'weight', + 'friendly_name': 'henk Muscle mass', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_muscle_mass', + 'last_changed': , + 'last_updated': , + 'state': '50.0', + }) +# --- +# name: test_all_entities.30 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Snoring episode count', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.henk_snoring_episode_count', + 'last_changed': , + 'last_updated': , + 'state': '348', + }) +# --- +# name: test_all_entities.31 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Wakeup count', + 'icon': 'mdi:sleep-off', + 'state_class': , + 'unit_of_measurement': 'times', + }), + 'context': , + 'entity_id': 'sensor.henk_wakeup_count', + 'last_changed': , + 'last_updated': , + 'state': '350', + }) +# --- +# name: test_all_entities.32 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'henk Wakeup time', + 'icon': 'mdi:sleep-off', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_wakeup_time', + 'last_changed': , + 'last_updated': , + 'state': '176.0', + }) +# --- +# name: test_all_entities.33 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Withings sleep_breathing_disturbances_intensity henk', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.withings_sleep_breathing_disturbances_intensity_henk', + 'last_changed': , + 'last_updated': , + 'state': '160.0', + }) +# --- +# name: test_all_entities.34 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.withings_sleep_deep_duration_seconds_henk', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:sleep', + 'original_name': 'Withings sleep_deep_duration_seconds henk', + 'platform': 'withings', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'withings_12345_sleep_deep_duration_seconds', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities.35 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Withings sleep_deep_duration_seconds henk', + 'icon': 'mdi:sleep', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.withings_sleep_deep_duration_seconds_henk', + 'last_changed': , + 'last_updated': , + 'state': '322', + }) +# --- +# name: test_all_entities.36 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.withings_sleep_tosleep_duration_seconds_henk', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:sleep', + 'original_name': 'Withings sleep_tosleep_duration_seconds henk', + 'platform': 'withings', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'withings_12345_sleep_tosleep_duration_seconds', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities.37 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Withings sleep_tosleep_duration_seconds henk', + 'icon': 'mdi:sleep', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.withings_sleep_tosleep_duration_seconds_henk', + 'last_changed': , + 'last_updated': , + 'state': '162.0', + }) +# --- +# name: test_all_entities.38 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.withings_sleep_towakeup_duration_seconds_henk', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:sleep-off', + 'original_name': 'Withings sleep_towakeup_duration_seconds henk', + 'platform': 'withings', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'withings_12345_sleep_towakeup_duration_seconds', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities.39 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Withings sleep_towakeup_duration_seconds henk', + 'icon': 'mdi:sleep-off', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.withings_sleep_towakeup_duration_seconds_henk', + 'last_changed': , + 'last_updated': , + 'state': '163.0', + }) +# --- +# name: test_all_entities.4 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'weight', + 'friendly_name': 'henk Bone mass', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_bone_mass', + 'last_changed': , + 'last_updated': , + 'state': '10.0', + }) +# --- +# name: test_all_entities.40 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.withings_sleep_heart_rate_average_bpm_henk', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:heart-pulse', + 'original_name': 'Withings sleep_heart_rate_average_bpm henk', + 'platform': 'withings', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'withings_12345_sleep_heart_rate_average_bpm', + 'unit_of_measurement': 'bpm', + }) +# --- +# name: test_all_entities.41 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Withings sleep_heart_rate_average_bpm henk', + 'icon': 'mdi:heart-pulse', + 'state_class': , + 'unit_of_measurement': 'bpm', + }), + 'context': , + 'entity_id': 'sensor.withings_sleep_heart_rate_average_bpm_henk', + 'last_changed': , + 'last_updated': , + 'state': '164.0', + }) +# --- +# name: test_all_entities.42 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.withings_sleep_heart_rate_max_bpm_henk', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:heart-pulse', + 'original_name': 'Withings sleep_heart_rate_max_bpm henk', + 'platform': 'withings', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'withings_12345_sleep_heart_rate_max_bpm', + 'unit_of_measurement': 'bpm', + }) +# --- +# name: test_all_entities.43 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Withings sleep_heart_rate_max_bpm henk', + 'icon': 'mdi:heart-pulse', + 'state_class': , + 'unit_of_measurement': 'bpm', + }), + 'context': , + 'entity_id': 'sensor.withings_sleep_heart_rate_max_bpm_henk', + 'last_changed': , + 'last_updated': , + 'state': '165.0', + }) +# --- +# name: test_all_entities.44 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.withings_sleep_heart_rate_min_bpm_henk', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:heart-pulse', + 'original_name': 'Withings sleep_heart_rate_min_bpm henk', + 'platform': 'withings', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'withings_12345_sleep_heart_rate_min_bpm', + 'unit_of_measurement': 'bpm', + }) +# --- +# name: test_all_entities.45 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Withings sleep_heart_rate_min_bpm henk', + 'icon': 'mdi:heart-pulse', + 'state_class': , + 'unit_of_measurement': 'bpm', + }), + 'context': , + 'entity_id': 'sensor.withings_sleep_heart_rate_min_bpm_henk', + 'last_changed': , + 'last_updated': , + 'state': '166.0', + }) +# --- +# name: test_all_entities.46 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.withings_sleep_light_duration_seconds_henk', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:sleep', + 'original_name': 'Withings sleep_light_duration_seconds henk', + 'platform': 'withings', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'withings_12345_sleep_light_duration_seconds', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities.47 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Withings sleep_light_duration_seconds henk', + 'icon': 'mdi:sleep', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.withings_sleep_light_duration_seconds_henk', + 'last_changed': , + 'last_updated': , + 'state': '334', + }) +# --- +# name: test_all_entities.48 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.withings_sleep_rem_duration_seconds_henk', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:sleep', + 'original_name': 'Withings sleep_rem_duration_seconds henk', + 'platform': 'withings', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'withings_12345_sleep_rem_duration_seconds', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities.49 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Withings sleep_rem_duration_seconds henk', + 'icon': 'mdi:sleep', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.withings_sleep_rem_duration_seconds_henk', + 'last_changed': , + 'last_updated': , + 'state': '336', + }) +# --- +# name: test_all_entities.5 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'henk Height', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_height', + 'last_changed': , + 'last_updated': , + 'state': '2.0', + }) +# --- +# name: test_all_entities.50 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.withings_sleep_respiratory_average_bpm_henk', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Withings sleep_respiratory_average_bpm henk', + 'platform': 'withings', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'withings_12345_sleep_respiratory_average_bpm', + 'unit_of_measurement': 'br/min', + }) +# --- +# name: test_all_entities.51 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Withings sleep_respiratory_average_bpm henk', + 'state_class': , + 'unit_of_measurement': 'br/min', + }), + 'context': , + 'entity_id': 'sensor.withings_sleep_respiratory_average_bpm_henk', + 'last_changed': , + 'last_updated': , + 'state': '169.0', + }) +# --- +# name: test_all_entities.52 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.withings_sleep_respiratory_max_bpm_henk', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Withings sleep_respiratory_max_bpm henk', + 'platform': 'withings', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'withings_12345_sleep_respiratory_max_bpm', + 'unit_of_measurement': 'br/min', + }) +# --- +# name: test_all_entities.53 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Withings sleep_respiratory_max_bpm henk', + 'state_class': , + 'unit_of_measurement': 'br/min', + }), + 'context': , + 'entity_id': 'sensor.withings_sleep_respiratory_max_bpm_henk', + 'last_changed': , + 'last_updated': , + 'state': '170.0', + }) +# --- +# name: test_all_entities.54 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.withings_sleep_respiratory_min_bpm_henk', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Withings sleep_respiratory_min_bpm henk', + 'platform': 'withings', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'withings_12345_sleep_respiratory_min_bpm', + 'unit_of_measurement': 'br/min', + }) +# --- +# name: test_all_entities.55 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Withings sleep_respiratory_min_bpm henk', + 'state_class': , + 'unit_of_measurement': 'br/min', + }), + 'context': , + 'entity_id': 'sensor.withings_sleep_respiratory_min_bpm_henk', + 'last_changed': , + 'last_updated': , + 'state': '171.0', + }) +# --- +# name: test_all_entities.56 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.withings_sleep_score_henk', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:medal', + 'original_name': 'Withings sleep_score henk', + 'platform': 'withings', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'withings_12345_sleep_score', + 'unit_of_measurement': 'points', + }) +# --- +# name: test_all_entities.57 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Withings sleep_score henk', + 'icon': 'mdi:medal', + 'state_class': , + 'unit_of_measurement': 'points', + }), + 'context': , + 'entity_id': 'sensor.withings_sleep_score_henk', + 'last_changed': , + 'last_updated': , + 'state': '222', + }) +# --- +# name: test_all_entities.58 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.withings_sleep_snoring_henk', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Withings sleep_snoring henk', + 'platform': 'withings', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'withings_12345_sleep_snoring', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities.59 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Withings sleep_snoring henk', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.withings_sleep_snoring_henk', + 'last_changed': , + 'last_updated': , + 'state': '173.0', + }) +# --- +# name: test_all_entities.6 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'henk Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_temperature', + 'last_changed': , + 'last_updated': , + 'state': '40.0', + }) +# --- +# name: test_all_entities.60 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.withings_sleep_snoring_eposode_count_henk', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Withings sleep_snoring_eposode_count henk', + 'platform': 'withings', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'withings_12345_sleep_snoring_eposode_count', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities.61 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Withings sleep_snoring_eposode_count henk', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.withings_sleep_snoring_eposode_count_henk', + 'last_changed': , + 'last_updated': , + 'state': '348', + }) +# --- +# name: test_all_entities.62 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.withings_sleep_wakeup_count_henk', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:sleep-off', + 'original_name': 'Withings sleep_wakeup_count henk', + 'platform': 'withings', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'withings_12345_sleep_wakeup_count', + 'unit_of_measurement': 'times', + }) +# --- +# name: test_all_entities.63 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Withings sleep_wakeup_count henk', + 'icon': 'mdi:sleep-off', + 'state_class': , + 'unit_of_measurement': 'times', + }), + 'context': , + 'entity_id': 'sensor.withings_sleep_wakeup_count_henk', + 'last_changed': , + 'last_updated': , + 'state': '350', + }) +# --- +# name: test_all_entities.64 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.withings_sleep_wakeup_duration_seconds_henk', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:sleep-off', + 'original_name': 'Withings sleep_wakeup_duration_seconds henk', + 'platform': 'withings', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'withings_12345_sleep_wakeup_duration_seconds', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities.65 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Withings sleep_wakeup_duration_seconds henk', + 'icon': 'mdi:sleep-off', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.withings_sleep_wakeup_duration_seconds_henk', + 'last_changed': , + 'last_updated': , + 'state': '176.0', + }) +# --- +# name: test_all_entities.7 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'henk Body temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_body_temperature', + 'last_changed': , + 'last_updated': , + 'state': '40.0', + }) +# --- +# name: test_all_entities.8 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'henk Skin temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_skin_temperature', + 'last_changed': , + 'last_updated': , + 'state': '20.0', + }) +# --- +# name: test_all_entities.9 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Fat ratio', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.henk_fat_ratio', + 'last_changed': , + 'last_updated': , + 'state': '0.07', + }) +# --- diff --git a/tests/components/withings/test_binary_sensor.py b/tests/components/withings/test_binary_sensor.py index 03d72c452960ce..dca9fbc6437c2d 100644 --- a/tests/components/withings/test_binary_sensor.py +++ b/tests/components/withings/test_binary_sensor.py @@ -1,76 +1,51 @@ """Tests for the Withings component.""" +from unittest.mock import AsyncMock + from withings_api.common import NotifyAppli -from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN -from homeassistant.components.withings.binary_sensor import BINARY_SENSORS -from homeassistant.components.withings.common import WithingsEntityDescription -from homeassistant.components.withings.const import Measurement from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_registry import EntityRegistry -from .common import ComponentFactory, async_get_entity_id, new_profile_config +from . import call_webhook, enable_webhooks, setup_integration +from .conftest import USER_ID, WEBHOOK_ID -WITHINGS_MEASUREMENTS_MAP: dict[Measurement, WithingsEntityDescription] = { - attr.measurement: attr for attr in BINARY_SENSORS -} +from tests.common import MockConfigEntry +from tests.typing import ClientSessionGenerator async def test_binary_sensor( hass: HomeAssistant, - component_factory: ComponentFactory, - current_request_with_host: None, + withings: AsyncMock, + disable_webhook_delay, + config_entry: MockConfigEntry, + hass_client_no_auth: ClientSessionGenerator, ) -> None: """Test binary sensor.""" - in_bed_attribute = WITHINGS_MEASUREMENTS_MAP[Measurement.IN_BED] - person0 = new_profile_config("person0", 0) - person1 = new_profile_config("person1", 1) + await enable_webhooks(hass) + await setup_integration(hass, config_entry) - entity_registry: EntityRegistry = er.async_get(hass) + client = await hass_client_no_auth() - await component_factory.configure_component(profile_configs=(person0, person1)) - assert not await async_get_entity_id( - hass, in_bed_attribute, person0.user_id, BINARY_SENSOR_DOMAIN - ) - assert not await async_get_entity_id( - hass, in_bed_attribute, person1.user_id, BINARY_SENSOR_DOMAIN - ) + entity_id = "binary_sensor.henk_in_bed" - # person 0 - await component_factory.setup_profile(person0.user_id) - await component_factory.setup_profile(person1.user_id) + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE - entity_id0 = await async_get_entity_id( - hass, in_bed_attribute, person0.user_id, BINARY_SENSOR_DOMAIN + resp = await call_webhook( + hass, + WEBHOOK_ID, + {"userid": USER_ID, "appli": NotifyAppli.BED_IN}, + client, ) - entity_id1 = await async_get_entity_id( - hass, in_bed_attribute, person1.user_id, BINARY_SENSOR_DOMAIN - ) - assert entity_id0 - assert entity_id1 - - assert entity_registry.async_is_registered(entity_id0) - assert hass.states.get(entity_id0).state == STATE_UNAVAILABLE - - resp = await component_factory.call_webhook(person0.user_id, NotifyAppli.BED_IN) assert resp.message_code == 0 await hass.async_block_till_done() - assert hass.states.get(entity_id0).state == STATE_ON + assert hass.states.get(entity_id).state == STATE_ON - resp = await component_factory.call_webhook(person0.user_id, NotifyAppli.BED_OUT) - assert resp.message_code == 0 - await hass.async_block_till_done() - assert hass.states.get(entity_id0).state == STATE_OFF - - # person 1 - assert hass.states.get(entity_id1).state == STATE_UNAVAILABLE - - resp = await component_factory.call_webhook(person1.user_id, NotifyAppli.BED_IN) + resp = await call_webhook( + hass, + WEBHOOK_ID, + {"userid": USER_ID, "appli": NotifyAppli.BED_OUT}, + client, + ) assert resp.message_code == 0 await hass.async_block_till_done() - assert hass.states.get(entity_id1).state == STATE_ON - - # Unload - await component_factory.unload(person0) - await component_factory.unload(person1) + assert hass.states.get(entity_id).state == STATE_OFF diff --git a/tests/components/withings/test_common.py b/tests/components/withings/test_common.py deleted file mode 100644 index 91915a47920af5..00000000000000 --- a/tests/components/withings/test_common.py +++ /dev/null @@ -1,237 +0,0 @@ -"""Tests for the Withings component.""" -import datetime -from http import HTTPStatus -import re -from typing import Any -from unittest.mock import MagicMock -from urllib.parse import urlparse - -from aiohttp.test_utils import TestClient -import pytest -import requests_mock -from withings_api.common import NotifyAppli, NotifyListProfile, NotifyListResponse - -from homeassistant.components.withings.common import ( - ConfigEntryWithingsApi, - DataManager, - WebhookConfig, -) -from homeassistant.core import HomeAssistant -from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2Implementation - -from .common import ComponentFactory, get_data_manager_by_user_id, new_profile_config - -from tests.common import MockConfigEntry -from tests.test_util.aiohttp import AiohttpClientMocker -from tests.typing import ClientSessionGenerator - - -async def test_config_entry_withings_api(hass: HomeAssistant) -> None: - """Test ConfigEntryWithingsApi.""" - config_entry = MockConfigEntry( - data={"token": {"access_token": "mock_access_token", "expires_at": 1111111}} - ) - config_entry.add_to_hass(hass) - - implementation_mock = MagicMock(spec=AbstractOAuth2Implementation) - implementation_mock.async_refresh_token.return_value = { - "expires_at": 1111111, - "access_token": "mock_access_token", - } - - with requests_mock.mock() as rqmck: - rqmck.get( - re.compile(".*"), - status_code=HTTPStatus.OK, - json={"status": 0, "body": {"message": "success"}}, - ) - - api = ConfigEntryWithingsApi(hass, config_entry, implementation_mock) - response = await hass.async_add_executor_job( - api.request, "test", {"arg1": "val1", "arg2": "val2"} - ) - assert response == {"message": "success"} - - -@pytest.mark.parametrize( - ("user_id", "arg_user_id", "arg_appli", "expected_code"), - [ - [0, 0, NotifyAppli.WEIGHT.value, 0], # Success - [0, None, 1, 0], # Success, we ignore the user_id. - [0, None, None, 12], # No request body. - [0, "GG", None, 20], # appli not provided. - [0, 0, None, 20], # appli not provided. - [0, 0, 99, 21], # Invalid appli. - [0, 11, NotifyAppli.WEIGHT.value, 0], # Success, we ignore the user_id - ], -) -async def test_webhook_post( - hass: HomeAssistant, - component_factory: ComponentFactory, - aiohttp_client: ClientSessionGenerator, - user_id: int, - arg_user_id: Any, - arg_appli: Any, - expected_code: int, - current_request_with_host: None, -) -> None: - """Test webhook callback.""" - person0 = new_profile_config("person0", user_id) - - await component_factory.configure_component(profile_configs=(person0,)) - await component_factory.setup_profile(person0.user_id) - data_manager = get_data_manager_by_user_id(hass, user_id) - - client: TestClient = await aiohttp_client(hass.http.app) - - post_data = {} - if arg_user_id is not None: - post_data["userid"] = arg_user_id - if arg_appli is not None: - post_data["appli"] = arg_appli - - resp = await client.post( - urlparse(data_manager.webhook_config.url).path, data=post_data - ) - - # Wait for remaining tasks to complete. - await hass.async_block_till_done() - - data = await resp.json() - resp.close() - - assert data["code"] == expected_code - - -async def test_webhook_head( - hass: HomeAssistant, - component_factory: ComponentFactory, - aiohttp_client: ClientSessionGenerator, - current_request_with_host: None, -) -> None: - """Test head method on webhook view.""" - person0 = new_profile_config("person0", 0) - - await component_factory.configure_component(profile_configs=(person0,)) - await component_factory.setup_profile(person0.user_id) - data_manager = get_data_manager_by_user_id(hass, person0.user_id) - - client: TestClient = await aiohttp_client(hass.http.app) - resp = await client.head(urlparse(data_manager.webhook_config.url).path) - assert resp.status == HTTPStatus.OK - - -async def test_webhook_put( - hass: HomeAssistant, - component_factory: ComponentFactory, - aiohttp_client: ClientSessionGenerator, - current_request_with_host: None, -) -> None: - """Test webhook callback.""" - person0 = new_profile_config("person0", 0) - - await component_factory.configure_component(profile_configs=(person0,)) - await component_factory.setup_profile(person0.user_id) - data_manager = get_data_manager_by_user_id(hass, person0.user_id) - - client: TestClient = await aiohttp_client(hass.http.app) - resp = await client.put(urlparse(data_manager.webhook_config.url).path) - - # Wait for remaining tasks to complete. - await hass.async_block_till_done() - - assert resp.status == HTTPStatus.OK - data = await resp.json() - assert data - assert data["code"] == 2 - - -async def test_data_manager_webhook_subscription( - hass: HomeAssistant, - component_factory: ComponentFactory, - aioclient_mock: AiohttpClientMocker, -) -> None: - """Test data manager webhook subscriptions.""" - person0 = new_profile_config("person0", 0) - await component_factory.configure_component(profile_configs=(person0,)) - - api: ConfigEntryWithingsApi = MagicMock(spec=ConfigEntryWithingsApi) - data_manager = DataManager( - hass, - "person0", - api, - 0, - WebhookConfig(id="1234", url="http://localhost/api/webhook/1234", enabled=True), - ) - - data_manager._notify_subscribe_delay = datetime.timedelta(seconds=0) - data_manager._notify_unsubscribe_delay = datetime.timedelta(seconds=0) - - api.notify_list.return_value = NotifyListResponse( - profiles=( - NotifyListProfile( - appli=NotifyAppli.BED_IN, - callbackurl="https://not.my.callback/url", - expires=None, - comment=None, - ), - NotifyListProfile( - appli=NotifyAppli.BED_IN, - callbackurl=data_manager.webhook_config.url, - expires=None, - comment=None, - ), - NotifyListProfile( - appli=NotifyAppli.BED_OUT, - callbackurl=data_manager.webhook_config.url, - expires=None, - comment=None, - ), - ) - ) - - aioclient_mock.clear_requests() - aioclient_mock.request( - "HEAD", - data_manager.webhook_config.url, - status=HTTPStatus.OK, - ) - - # Test subscribing - await data_manager.async_subscribe_webhook() - api.notify_subscribe.assert_any_call( - data_manager.webhook_config.url, NotifyAppli.WEIGHT - ) - api.notify_subscribe.assert_any_call( - data_manager.webhook_config.url, NotifyAppli.CIRCULATORY - ) - api.notify_subscribe.assert_any_call( - data_manager.webhook_config.url, NotifyAppli.ACTIVITY - ) - api.notify_subscribe.assert_any_call( - data_manager.webhook_config.url, NotifyAppli.SLEEP - ) - - with pytest.raises(AssertionError): - api.notify_subscribe.assert_any_call( - data_manager.webhook_config.url, NotifyAppli.USER - ) - - with pytest.raises(AssertionError): - api.notify_subscribe.assert_any_call( - data_manager.webhook_config.url, NotifyAppli.BED_IN - ) - - with pytest.raises(AssertionError): - api.notify_subscribe.assert_any_call( - data_manager.webhook_config.url, NotifyAppli.BED_OUT - ) - - # Test unsubscribing. - await data_manager.async_unsubscribe_webhook() - api.notify_revoke.assert_any_call( - data_manager.webhook_config.url, NotifyAppli.BED_IN - ) - api.notify_revoke.assert_any_call( - data_manager.webhook_config.url, NotifyAppli.BED_OUT - ) diff --git a/tests/components/withings/test_config_flow.py b/tests/components/withings/test_config_flow.py index c8f3b4bbb29ae5..d5745ae9bedf07 100644 --- a/tests/components/withings/test_config_flow.py +++ b/tests/components/withings/test_config_flow.py @@ -1,90 +1,224 @@ """Tests for config flow.""" -from http import HTTPStatus - -from aiohttp.test_utils import TestClient - -from homeassistant import config_entries -from homeassistant.components.withings import const -from homeassistant.config import async_process_ha_core_config -from homeassistant.const import ( - CONF_CLIENT_ID, - CONF_CLIENT_SECRET, - CONF_EXTERNAL_URL, - CONF_UNIT_SYSTEM, - CONF_UNIT_SYSTEM_METRIC, -) -from homeassistant.core import DOMAIN as HA_DOMAIN, HomeAssistant +from unittest.mock import AsyncMock, patch + +from homeassistant.components.withings.const import CONF_USE_WEBHOOK, DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, 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.config_entry_oauth2_flow import AUTH_CALLBACK_PATH -from homeassistant.setup import async_setup_component + +from . import setup_integration +from .conftest import CLIENT_ID, USER_ID from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator -async def test_config_non_unique_profile(hass: HomeAssistant) -> None: - """Test setup a non-unique profile.""" - config_entry = MockConfigEntry( - domain=const.DOMAIN, data={const.PROFILE: "person0"}, unique_id="0" +async def test_full_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + current_request_with_host: None, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Check full flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, ) - config_entry.add_to_hass(hass) + assert result["type"] == FlowResultType.EXTERNAL_STEP + assert result["url"] == ( + "https://account.withings.com/oauth2_user/authorize2?" + f"response_type=code&client_id={CLIENT_ID}&" + "redirect_uri=https://example.com/auth/external/callback&" + f"state={state}" + "&scope=user.info,user.metrics,user.activity,user.sleepevents" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.clear_requests() + aioclient_mock.post( + "https://wbsapi.withings.net/v2/oauth2", + json={ + "body": { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + "userid": 600, + }, + }, + ) + with patch( + "homeassistant.components.withings.async_setup_entry", return_value=True + ) as mock_setup: + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup.mock_calls) == 1 + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Withings" + assert "result" in result + assert result["result"].unique_id == "600" + assert "token" in result["result"].data + assert "webhook_id" in result["result"].data + assert result["result"].data["token"]["access_token"] == "mock-access-token" + assert result["result"].data["token"]["refresh_token"] == "mock-refresh-token" + + +async def test_config_non_unique_profile( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + current_request_with_host: None, + withings: AsyncMock, + config_entry: MockConfigEntry, + disable_webhook_delay, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test setup a non-unique profile.""" + await setup_integration(hass, config_entry) result = await hass.config_entries.flow.async_init( - const.DOMAIN, context={"source": "profile"}, data={const.PROFILE: "person0"} + DOMAIN, context={"source": SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, ) - assert result - assert result["errors"]["base"] == "already_configured" + assert result["type"] == FlowResultType.EXTERNAL_STEP + assert result["url"] == ( + "https://account.withings.com/oauth2_user/authorize2?" + f"response_type=code&client_id={CLIENT_ID}&" + "redirect_uri=https://example.com/auth/external/callback&" + f"state={state}" + "&scope=user.info,user.metrics,user.activity,user.sleepevents" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.clear_requests() + aioclient_mock.post( + "https://wbsapi.withings.net/v2/oauth2", + json={ + "body": { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + "userid": USER_ID, + }, + }, + ) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" async def test_config_reauth_profile( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - current_request_with_host: None, + config_entry: MockConfigEntry, + withings: AsyncMock, + disable_webhook_delay, + current_request_with_host, ) -> None: - """Test reauth an existing profile re-creates the config entry.""" - hass_config = { - HA_DOMAIN: { - CONF_UNIT_SYSTEM: CONF_UNIT_SYSTEM_METRIC, - CONF_EXTERNAL_URL: "http://127.0.0.1:8080/", + """Test reauth an existing profile reauthenticates the config entry.""" + await setup_integration(hass, config_entry) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "entry_id": config_entry.entry_id, }, - const.DOMAIN: { - CONF_CLIENT_ID: "my_client_id", - CONF_CLIENT_SECRET: "my_client_secret", - const.CONF_USE_WEBHOOK: False, + data=config_entry.data, + ) + assert result["type"] == "form" + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", }, - } - await async_process_ha_core_config(hass, hass_config.get(HA_DOMAIN)) - assert await async_setup_component(hass, const.DOMAIN, hass_config) - await hass.async_block_till_done() + ) + assert result["url"] == ( + "https://account.withings.com/oauth2_user/authorize2?" + f"response_type=code&client_id={CLIENT_ID}&" + "redirect_uri=https://example.com/auth/external/callback&" + f"state={state}" + "&scope=user.info,user.metrics,user.activity,user.sleepevents" + ) + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" - config_entry = MockConfigEntry( - domain=const.DOMAIN, data={const.PROFILE: "person0"}, unique_id="0" + aioclient_mock.clear_requests() + aioclient_mock.post( + "https://wbsapi.withings.net/v2/oauth2", + json={ + "body": { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + "userid": USER_ID, + }, + }, ) - config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + +async def test_config_reauth_wrong_account( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + config_entry: MockConfigEntry, + withings: AsyncMock, + disable_webhook_delay, + current_request_with_host, +) -> None: + """Test reauth with wrong account.""" + await setup_integration(hass, config_entry) result = await hass.config_entries.flow.async_init( - const.DOMAIN, + DOMAIN, context={ - "source": config_entries.SOURCE_REAUTH, + "source": SOURCE_REAUTH, "entry_id": config_entry.entry_id, - "title_placeholders": {"name": config_entry.title}, - "unique_id": config_entry.unique_id, }, - data={"profile": "person0"}, + data=config_entry.data, ) - assert result assert result["type"] == "form" assert result["step_id"] == "reauth_confirm" - assert result["description_placeholders"] == {const.PROFILE: "person0"} - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {}, - ) + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) state = config_entry_oauth2_flow._encode_jwt( hass, { @@ -92,10 +226,16 @@ async def test_config_reauth_profile( "redirect_uri": "https://example.com/auth/external/callback", }, ) - - client: TestClient = await hass_client_no_auth() - resp = await client.get(f"{AUTH_CALLBACK_PATH}?code=abcd&state={state}") - assert resp.status == HTTPStatus.OK + assert result["url"] == ( + "https://account.withings.com/oauth2_user/authorize2?" + f"response_type=code&client_id={CLIENT_ID}&" + "redirect_uri=https://example.com/auth/external/callback&" + f"state={state}" + "&scope=user.info,user.metrics,user.activity,user.sleepevents" + ) + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 assert resp.headers["content-type"] == "text/html; charset=utf-8" aioclient_mock.clear_requests() @@ -107,16 +247,40 @@ async def test_config_reauth_profile( "access_token": "mock-access-token", "type": "Bearer", "expires_in": 60, - "userid": "0", + "userid": 12346, }, }, ) result = await hass.config_entries.flow.async_configure(result["flow_id"]) assert result - assert result["type"] == "abort" - assert result["reason"] == "already_configured" + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "wrong_account" + + +async def test_options_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + config_entry: MockConfigEntry, + withings: AsyncMock, + disable_webhook_delay, + current_request_with_host, +) -> None: + """Test options flow.""" + await setup_integration(hass, config_entry) + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={CONF_USE_WEBHOOK: True}, + ) + await hass.async_block_till_done() - entries = hass.config_entries.async_entries(const.DOMAIN) - assert entries - assert entries[0].data["token"]["refresh_token"] == "mock-refresh-token" + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == {CONF_USE_WEBHOOK: True} diff --git a/tests/components/withings/test_init.py b/tests/components/withings/test_init.py index 9ccc53d0b88330..15f0fff808d88e 100644 --- a/tests/components/withings/test_init.py +++ b/tests/components/withings/test_init.py @@ -1,28 +1,24 @@ """Tests for the Withings component.""" -from unittest.mock import MagicMock, patch +from datetime import timedelta +from typing import Any +from unittest.mock import AsyncMock, MagicMock +from urllib.parse import urlparse import pytest import voluptuous as vol -from withings_api.common import UnauthorizedException +from withings_api.common import NotifyAppli -import homeassistant.components.webhook as webhook +from homeassistant.components.webhook import async_generate_url from homeassistant.components.withings import CONFIG_SCHEMA, DOMAIN, async_setup, const -from homeassistant.components.withings.common import ConfigEntryWithingsApi, DataManager -from homeassistant.config import async_process_ha_core_config -from homeassistant.const import ( - CONF_CLIENT_ID, - CONF_CLIENT_SECRET, - CONF_EXTERNAL_URL, - CONF_UNIT_SYSTEM, - CONF_UNIT_SYSTEM_METRIC, -) -from homeassistant.core import DOMAIN as HA_DOMAIN, HomeAssistant -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from homeassistant.setup import async_setup_component +from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_WEBHOOK_ID +from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util -from .common import ComponentFactory, get_data_manager_by_user_id, new_profile_config +from . import enable_webhooks, setup_integration +from .conftest import WEBHOOK_ID -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed +from tests.typing import ClientSessionGenerator def config_schema_validate(withings_config) -> dict: @@ -106,121 +102,142 @@ async def test_async_setup_no_config(hass: HomeAssistant) -> None: hass.async_create_task.assert_not_called() +async def test_data_manager_webhook_subscription( + hass: HomeAssistant, + withings: AsyncMock, + disable_webhook_delay, + config_entry: MockConfigEntry, + hass_client_no_auth: ClientSessionGenerator, +) -> None: + """Test data manager webhook subscriptions.""" + await enable_webhooks(hass) + await setup_integration(hass, config_entry) + await hass_client_no_auth() + await hass.async_block_till_done() + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=1)) + await hass.async_block_till_done() + + assert withings.async_notify_subscribe.call_count == 4 + + webhook_url = "http://example.local:8123/api/webhook/55a7335ea8dee830eed4ef8f84cda8f6d80b83af0847dc74032e86120bffed5e" + + withings.async_notify_subscribe.assert_any_call(webhook_url, NotifyAppli.WEIGHT) + withings.async_notify_subscribe.assert_any_call( + webhook_url, NotifyAppli.CIRCULATORY + ) + withings.async_notify_subscribe.assert_any_call(webhook_url, NotifyAppli.ACTIVITY) + withings.async_notify_subscribe.assert_any_call(webhook_url, NotifyAppli.SLEEP) + + withings.async_notify_revoke.assert_any_call(webhook_url, NotifyAppli.BED_IN) + withings.async_notify_revoke.assert_any_call(webhook_url, NotifyAppli.BED_OUT) + + @pytest.mark.parametrize( - "exception", + "method", [ - UnauthorizedException("401"), - UnauthorizedException("401"), - Exception("401, this is the message"), + "PUT", + "HEAD", ], ) -@patch("homeassistant.components.withings.common._RETRY_COEFFICIENT", 0) -async def test_auth_failure( +async def test_requests( hass: HomeAssistant, - component_factory: ComponentFactory, - exception: Exception, - current_request_with_host: None, + withings: AsyncMock, + config_entry: MockConfigEntry, + hass_client_no_auth: ClientSessionGenerator, + method: str, + disable_webhook_delay, ) -> None: - """Test auth failure.""" - person0 = new_profile_config( - "person0", - 0, - api_response_user_get_device=exception, - api_response_measure_get_meas=exception, - api_response_sleep_get_summary=exception, + """Test we handle request methods Withings sends.""" + await setup_integration(hass, config_entry) + client = await hass_client_no_auth() + webhook_url = async_generate_url(hass, WEBHOOK_ID) + + response = await client.request( + method=method, + path=urlparse(webhook_url).path, ) + assert response.status == 200 - await component_factory.configure_component(profile_configs=(person0,)) - assert not hass.config_entries.flow.async_progress() - await component_factory.setup_profile(person0.user_id) - data_manager = get_data_manager_by_user_id(hass, person0.user_id) - await data_manager.poll_data_update_coordinator.async_refresh() - - flows = hass.config_entries.flow.async_progress() - assert flows - assert len(flows) == 1 +@pytest.mark.parametrize( + ("config_entry"), + [ + MockConfigEntry( + domain=DOMAIN, + unique_id="123", + data={ + "token": {"userid": 123}, + "profile": "henk", + "use_webhook": False, + "webhook_id": "3290798afaebd28519c4883d3d411c7197572e0cc9b8d507471f59a700a61a55", + }, + ), + MockConfigEntry( + domain=DOMAIN, + unique_id="123", + data={ + "token": {"userid": 123}, + "profile": "henk", + "use_webhook": False, + }, + ), + ], +) +async def test_config_flow_upgrade( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test config flow upgrade.""" + config_entry.add_to_hass(hass) - flow = flows[0] - assert flow["handler"] == const.DOMAIN + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() - result = await hass.config_entries.flow.async_configure( - flow["flow_id"], user_input={} - ) - assert result - assert result["type"] == "external" - assert result["handler"] == const.DOMAIN - assert result["step_id"] == "auth" + entry = hass.config_entries.async_get_entry(config_entry.entry_id) - await component_factory.unload(person0) + assert entry.unique_id == "123" + assert entry.data["token"]["userid"] == 123 + assert CONF_WEBHOOK_ID in entry.data + assert entry.options == { + "use_webhook": False, + } -async def test_set_config_unique_id( - hass: HomeAssistant, component_factory: ComponentFactory +@pytest.mark.parametrize( + ("body", "expected_code"), + [ + [{"userid": 0, "appli": NotifyAppli.WEIGHT.value}, 0], # Success + [{"userid": None, "appli": 1}, 0], # Success, we ignore the user_id. + [{}, 12], # No request body. + [{"userid": "GG"}, 20], # appli not provided. + [{"userid": 0}, 20], # appli not provided. + [{"userid": 0, "appli": 99}, 21], # Invalid appli. + [ + {"userid": 11, "appli": NotifyAppli.WEIGHT.value}, + 0, + ], # Success, we ignore the user_id + ], +) +async def test_webhook_post( + hass: HomeAssistant, + withings: AsyncMock, + config_entry: MockConfigEntry, + hass_client_no_auth: ClientSessionGenerator, + disable_webhook_delay, + body: dict[str, Any], + expected_code: int, + current_request_with_host: None, ) -> None: - """Test upgrading configs to use a unique id.""" - person0 = new_profile_config("person0", 0) - - await component_factory.configure_component(profile_configs=(person0,)) - - config_entry = MockConfigEntry( - domain=DOMAIN, - data={ - "token": {"userid": "my_user_id"}, - "auth_implementation": "withings", - "profile": person0.profile, - }, - ) + """Test webhook callback.""" + await setup_integration(hass, config_entry) + client = await hass_client_no_auth() + webhook_url = async_generate_url(hass, WEBHOOK_ID) - with patch("homeassistant.components.withings.async_get_data_manager") as mock: - data_manager: DataManager = MagicMock(spec=DataManager) - data_manager.poll_data_update_coordinator = MagicMock( - spec=DataUpdateCoordinator - ) - data_manager.poll_data_update_coordinator.last_update_success = True - data_manager.subscription_update_coordinator = MagicMock( - spec=DataUpdateCoordinator - ) - data_manager.subscription_update_coordinator.last_update_success = True - mock.return_value = data_manager - config_entry.add_to_hass(hass) - - await hass.config_entries.async_setup(config_entry.entry_id) - assert config_entry.unique_id == "my_user_id" - - -async def test_set_convert_unique_id_to_string(hass: HomeAssistant) -> None: - """Test upgrading configs to use a unique id.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data={ - "token": {"userid": 1234}, - "auth_implementation": "withings", - "profile": "person0", - }, - ) - config_entry.add_to_hass(hass) + resp = await client.post(urlparse(webhook_url).path, data=body) - hass_config = { - HA_DOMAIN: { - CONF_UNIT_SYSTEM: CONF_UNIT_SYSTEM_METRIC, - CONF_EXTERNAL_URL: "http://127.0.0.1:8080/", - }, - const.DOMAIN: { - CONF_CLIENT_ID: "my_client_id", - CONF_CLIENT_SECRET: "my_client_secret", - const.CONF_USE_WEBHOOK: False, - }, - } + # Wait for remaining tasks to complete. + await hass.async_block_till_done() + + data = await resp.json() + resp.close() - with patch( - "homeassistant.components.withings.common.ConfigEntryWithingsApi", - spec=ConfigEntryWithingsApi, - ): - await async_process_ha_core_config(hass, hass_config.get(HA_DOMAIN)) - assert await async_setup_component(hass, HA_DOMAIN, {}) - assert await async_setup_component(hass, webhook.DOMAIN, hass_config) - assert await async_setup_component(hass, const.DOMAIN, hass_config) - await hass.async_block_till_done() - - assert config_entry.unique_id == "1234" + assert data["code"] == expected_code diff --git a/tests/components/withings/test_sensor.py b/tests/components/withings/test_sensor.py index 6c4bb867f75424..cf0069c968a6f0 100644 --- a/tests/components/withings/test_sensor.py +++ b/tests/components/withings/test_sensor.py @@ -1,291 +1,62 @@ """Tests for the Withings component.""" from typing import Any -from unittest.mock import patch +from unittest.mock import AsyncMock -import arrow -from withings_api.common import ( - GetSleepSummaryData, - GetSleepSummarySerie, - MeasureGetMeasGroup, - MeasureGetMeasGroupAttrib, - MeasureGetMeasGroupCategory, - MeasureGetMeasMeasure, - MeasureGetMeasResponse, - MeasureType, - NotifyAppli, - SleepGetSummaryResponse, - SleepModel, -) +import pytest +from syrupy import SnapshotAssertion +from withings_api.common import NotifyAppli from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.components.withings.common import WithingsEntityDescription from homeassistant.components.withings.const import Measurement +from homeassistant.components.withings.entity import WithingsEntityDescription from homeassistant.components.withings.sensor import SENSORS from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_registry import EntityRegistry -from homeassistant.util import dt as dt_util -from .common import ComponentFactory, async_get_entity_id, new_profile_config +from . import call_webhook, setup_integration +from .common import async_get_entity_id +from .conftest import USER_ID, WEBHOOK_ID + +from tests.common import MockConfigEntry +from tests.typing import ClientSessionGenerator WITHINGS_MEASUREMENTS_MAP: dict[Measurement, WithingsEntityDescription] = { attr.measurement: attr for attr in SENSORS } -PERSON0 = new_profile_config( - "person0", - 0, - api_response_measure_get_meas=MeasureGetMeasResponse( - measuregrps=( - MeasureGetMeasGroup( - attrib=MeasureGetMeasGroupAttrib.DEVICE_ENTRY_FOR_USER, - category=MeasureGetMeasGroupCategory.REAL, - created=arrow.utcnow().shift(hours=-1), - date=arrow.utcnow().shift(hours=-1), - deviceid="DEV_ID", - grpid=1, - measures=( - MeasureGetMeasMeasure(type=MeasureType.WEIGHT, unit=0, value=70), - MeasureGetMeasMeasure( - type=MeasureType.FAT_MASS_WEIGHT, unit=0, value=5 - ), - MeasureGetMeasMeasure( - type=MeasureType.FAT_FREE_MASS, unit=0, value=60 - ), - MeasureGetMeasMeasure( - type=MeasureType.MUSCLE_MASS, unit=0, value=50 - ), - MeasureGetMeasMeasure(type=MeasureType.BONE_MASS, unit=0, value=10), - MeasureGetMeasMeasure(type=MeasureType.HEIGHT, unit=0, value=2), - MeasureGetMeasMeasure( - type=MeasureType.TEMPERATURE, unit=0, value=40 - ), - MeasureGetMeasMeasure( - type=MeasureType.BODY_TEMPERATURE, unit=0, value=40 - ), - MeasureGetMeasMeasure( - type=MeasureType.SKIN_TEMPERATURE, unit=0, value=20 - ), - MeasureGetMeasMeasure( - type=MeasureType.FAT_RATIO, unit=-3, value=70 - ), - MeasureGetMeasMeasure( - type=MeasureType.DIASTOLIC_BLOOD_PRESSURE, unit=0, value=70 - ), - MeasureGetMeasMeasure( - type=MeasureType.SYSTOLIC_BLOOD_PRESSURE, unit=0, value=100 - ), - MeasureGetMeasMeasure( - type=MeasureType.HEART_RATE, unit=0, value=60 - ), - MeasureGetMeasMeasure(type=MeasureType.SP02, unit=-2, value=95), - MeasureGetMeasMeasure( - type=MeasureType.HYDRATION, unit=-2, value=95 - ), - MeasureGetMeasMeasure( - type=MeasureType.PULSE_WAVE_VELOCITY, unit=0, value=100 - ), - ), - ), - MeasureGetMeasGroup( - attrib=MeasureGetMeasGroupAttrib.DEVICE_ENTRY_FOR_USER, - category=MeasureGetMeasGroupCategory.REAL, - created=arrow.utcnow().shift(hours=-2), - date=arrow.utcnow().shift(hours=-2), - deviceid="DEV_ID", - grpid=1, - measures=( - MeasureGetMeasMeasure(type=MeasureType.WEIGHT, unit=0, value=71), - MeasureGetMeasMeasure( - type=MeasureType.FAT_MASS_WEIGHT, unit=0, value=51 - ), - MeasureGetMeasMeasure( - type=MeasureType.FAT_FREE_MASS, unit=0, value=61 - ), - MeasureGetMeasMeasure( - type=MeasureType.MUSCLE_MASS, unit=0, value=51 - ), - MeasureGetMeasMeasure(type=MeasureType.BONE_MASS, unit=0, value=11), - MeasureGetMeasMeasure(type=MeasureType.HEIGHT, unit=0, value=21), - MeasureGetMeasMeasure( - type=MeasureType.TEMPERATURE, unit=0, value=41 - ), - MeasureGetMeasMeasure( - type=MeasureType.BODY_TEMPERATURE, unit=0, value=41 - ), - MeasureGetMeasMeasure( - type=MeasureType.SKIN_TEMPERATURE, unit=0, value=21 - ), - MeasureGetMeasMeasure( - type=MeasureType.FAT_RATIO, unit=-3, value=71 - ), - MeasureGetMeasMeasure( - type=MeasureType.DIASTOLIC_BLOOD_PRESSURE, unit=0, value=71 - ), - MeasureGetMeasMeasure( - type=MeasureType.SYSTOLIC_BLOOD_PRESSURE, unit=0, value=101 - ), - MeasureGetMeasMeasure( - type=MeasureType.HEART_RATE, unit=0, value=61 - ), - MeasureGetMeasMeasure(type=MeasureType.SP02, unit=-2, value=96), - MeasureGetMeasMeasure( - type=MeasureType.HYDRATION, unit=-2, value=96 - ), - MeasureGetMeasMeasure( - type=MeasureType.PULSE_WAVE_VELOCITY, unit=0, value=101 - ), - ), - ), - MeasureGetMeasGroup( - attrib=MeasureGetMeasGroupAttrib.DEVICE_ENTRY_FOR_USER_AMBIGUOUS, - category=MeasureGetMeasGroupCategory.REAL, - created=arrow.utcnow(), - date=arrow.utcnow(), - deviceid="DEV_ID", - grpid=1, - measures=( - MeasureGetMeasMeasure(type=MeasureType.WEIGHT, unit=0, value=71), - MeasureGetMeasMeasure( - type=MeasureType.FAT_MASS_WEIGHT, unit=0, value=4 - ), - MeasureGetMeasMeasure( - type=MeasureType.FAT_FREE_MASS, unit=0, value=40 - ), - MeasureGetMeasMeasure( - type=MeasureType.MUSCLE_MASS, unit=0, value=51 - ), - MeasureGetMeasMeasure(type=MeasureType.BONE_MASS, unit=0, value=11), - MeasureGetMeasMeasure(type=MeasureType.HEIGHT, unit=0, value=201), - MeasureGetMeasMeasure( - type=MeasureType.TEMPERATURE, unit=0, value=41 - ), - MeasureGetMeasMeasure( - type=MeasureType.BODY_TEMPERATURE, unit=0, value=34 - ), - MeasureGetMeasMeasure( - type=MeasureType.SKIN_TEMPERATURE, unit=0, value=21 - ), - MeasureGetMeasMeasure( - type=MeasureType.FAT_RATIO, unit=-3, value=71 - ), - MeasureGetMeasMeasure( - type=MeasureType.DIASTOLIC_BLOOD_PRESSURE, unit=0, value=71 - ), - MeasureGetMeasMeasure( - type=MeasureType.SYSTOLIC_BLOOD_PRESSURE, unit=0, value=101 - ), - MeasureGetMeasMeasure( - type=MeasureType.HEART_RATE, unit=0, value=61 - ), - MeasureGetMeasMeasure(type=MeasureType.SP02, unit=-2, value=98), - MeasureGetMeasMeasure( - type=MeasureType.HYDRATION, unit=-2, value=96 - ), - MeasureGetMeasMeasure( - type=MeasureType.PULSE_WAVE_VELOCITY, unit=0, value=102 - ), - ), - ), - ), - more=False, - timezone=dt_util.UTC, - updatetime=arrow.get("2019-08-01"), - offset=0, - ), - api_response_sleep_get_summary=SleepGetSummaryResponse( - more=False, - offset=0, - series=( - GetSleepSummarySerie( - timezone=dt_util.UTC, - model=SleepModel.SLEEP_MONITOR, - startdate=arrow.get("2019-02-01"), - enddate=arrow.get("2019-02-01"), - date=arrow.get("2019-02-01"), - modified=arrow.get(12345), - data=GetSleepSummaryData( - breathing_disturbances_intensity=110, - deepsleepduration=111, - durationtosleep=112, - durationtowakeup=113, - hr_average=114, - hr_max=115, - hr_min=116, - lightsleepduration=117, - remsleepduration=118, - rr_average=119, - rr_max=120, - rr_min=121, - sleep_score=122, - snoring=123, - snoringepisodecount=124, - wakeupcount=125, - wakeupduration=126, - ), - ), - GetSleepSummarySerie( - timezone=dt_util.UTC, - model=SleepModel.SLEEP_MONITOR, - startdate=arrow.get("2019-02-01"), - enddate=arrow.get("2019-02-01"), - date=arrow.get("2019-02-01"), - modified=arrow.get(12345), - data=GetSleepSummaryData( - breathing_disturbances_intensity=210, - deepsleepduration=211, - durationtosleep=212, - durationtowakeup=213, - hr_average=214, - hr_max=215, - hr_min=216, - lightsleepduration=217, - remsleepduration=218, - rr_average=219, - rr_max=220, - rr_min=221, - sleep_score=222, - snoring=223, - snoringepisodecount=224, - wakeupcount=225, - wakeupduration=226, - ), - ), - ), - ), -) EXPECTED_DATA = ( - (PERSON0, Measurement.WEIGHT_KG, 70.0), - (PERSON0, Measurement.FAT_MASS_KG, 5.0), - (PERSON0, Measurement.FAT_FREE_MASS_KG, 60.0), - (PERSON0, Measurement.MUSCLE_MASS_KG, 50.0), - (PERSON0, Measurement.BONE_MASS_KG, 10.0), - (PERSON0, Measurement.HEIGHT_M, 2.0), - (PERSON0, Measurement.FAT_RATIO_PCT, 0.07), - (PERSON0, Measurement.DIASTOLIC_MMHG, 70.0), - (PERSON0, Measurement.SYSTOLIC_MMGH, 100.0), - (PERSON0, Measurement.HEART_PULSE_BPM, 60.0), - (PERSON0, Measurement.SPO2_PCT, 0.95), - (PERSON0, Measurement.HYDRATION, 0.95), - (PERSON0, Measurement.PWV, 100.0), - (PERSON0, Measurement.SLEEP_BREATHING_DISTURBANCES_INTENSITY, 160.0), - (PERSON0, Measurement.SLEEP_DEEP_DURATION_SECONDS, 322), - (PERSON0, Measurement.SLEEP_HEART_RATE_AVERAGE, 164.0), - (PERSON0, Measurement.SLEEP_HEART_RATE_MAX, 165.0), - (PERSON0, Measurement.SLEEP_HEART_RATE_MIN, 166.0), - (PERSON0, Measurement.SLEEP_LIGHT_DURATION_SECONDS, 334), - (PERSON0, Measurement.SLEEP_REM_DURATION_SECONDS, 336), - (PERSON0, Measurement.SLEEP_RESPIRATORY_RATE_AVERAGE, 169.0), - (PERSON0, Measurement.SLEEP_RESPIRATORY_RATE_MAX, 170.0), - (PERSON0, Measurement.SLEEP_RESPIRATORY_RATE_MIN, 171.0), - (PERSON0, Measurement.SLEEP_SCORE, 222), - (PERSON0, Measurement.SLEEP_SNORING, 173.0), - (PERSON0, Measurement.SLEEP_SNORING_EPISODE_COUNT, 348), - (PERSON0, Measurement.SLEEP_TOSLEEP_DURATION_SECONDS, 162.0), - (PERSON0, Measurement.SLEEP_TOWAKEUP_DURATION_SECONDS, 163.0), - (PERSON0, Measurement.SLEEP_WAKEUP_COUNT, 350), - (PERSON0, Measurement.SLEEP_WAKEUP_DURATION_SECONDS, 176.0), + (Measurement.WEIGHT_KG, 70.0), + (Measurement.FAT_MASS_KG, 5.0), + (Measurement.FAT_FREE_MASS_KG, 60.0), + (Measurement.MUSCLE_MASS_KG, 50.0), + (Measurement.BONE_MASS_KG, 10.0), + (Measurement.HEIGHT_M, 2.0), + (Measurement.FAT_RATIO_PCT, 0.07), + (Measurement.DIASTOLIC_MMHG, 70.0), + (Measurement.SYSTOLIC_MMGH, 100.0), + (Measurement.HEART_PULSE_BPM, 60.0), + (Measurement.SPO2_PCT, 0.95), + (Measurement.HYDRATION, 0.95), + (Measurement.PWV, 100.0), + (Measurement.SLEEP_BREATHING_DISTURBANCES_INTENSITY, 160.0), + (Measurement.SLEEP_DEEP_DURATION_SECONDS, 322), + (Measurement.SLEEP_HEART_RATE_AVERAGE, 164.0), + (Measurement.SLEEP_HEART_RATE_MAX, 165.0), + (Measurement.SLEEP_HEART_RATE_MIN, 166.0), + (Measurement.SLEEP_LIGHT_DURATION_SECONDS, 334), + (Measurement.SLEEP_REM_DURATION_SECONDS, 336), + (Measurement.SLEEP_RESPIRATORY_RATE_AVERAGE, 169.0), + (Measurement.SLEEP_RESPIRATORY_RATE_MAX, 170.0), + (Measurement.SLEEP_RESPIRATORY_RATE_MIN, 171.0), + (Measurement.SLEEP_SCORE, 222), + (Measurement.SLEEP_SNORING, 173.0), + (Measurement.SLEEP_SNORING_EPISODE_COUNT, 348), + (Measurement.SLEEP_TOSLEEP_DURATION_SECONDS, 162.0), + (Measurement.SLEEP_TOWAKEUP_DURATION_SECONDS, 163.0), + (Measurement.SLEEP_WAKEUP_COUNT, 350), + (Measurement.SLEEP_WAKEUP_DURATION_SECONDS, 176.0), ) @@ -304,101 +75,58 @@ def async_assert_state_equals( ) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_sensor_default_enabled_entities( hass: HomeAssistant, - component_factory: ComponentFactory, - current_request_with_host: None, + withings: AsyncMock, + config_entry: MockConfigEntry, + disable_webhook_delay, + hass_client_no_auth: ClientSessionGenerator, ) -> None: """Test entities enabled by default.""" + await setup_integration(hass, config_entry) entity_registry: EntityRegistry = er.async_get(hass) - await component_factory.configure_component(profile_configs=(PERSON0,)) - - # Assert entities should not exist yet. - for attribute in SENSORS: - assert not await async_get_entity_id( - hass, attribute, PERSON0.user_id, SENSOR_DOMAIN - ) - - # person 0 - await component_factory.setup_profile(PERSON0.user_id) - + client = await hass_client_no_auth() # Assert entities should exist. for attribute in SENSORS: - entity_id = await async_get_entity_id( - hass, attribute, PERSON0.user_id, SENSOR_DOMAIN - ) + entity_id = await async_get_entity_id(hass, attribute, USER_ID, SENSOR_DOMAIN) assert entity_id assert entity_registry.async_is_registered(entity_id) - - resp = await component_factory.call_webhook(PERSON0.user_id, NotifyAppli.SLEEP) + resp = await call_webhook( + hass, + WEBHOOK_ID, + {"userid": USER_ID, "appli": NotifyAppli.SLEEP}, + client, + ) assert resp.message_code == 0 - - resp = await component_factory.call_webhook(PERSON0.user_id, NotifyAppli.WEIGHT) + resp = await call_webhook( + hass, + WEBHOOK_ID, + {"userid": USER_ID, "appli": NotifyAppli.WEIGHT}, + client, + ) assert resp.message_code == 0 - for person, measurement, expected in EXPECTED_DATA: + for measurement, expected in EXPECTED_DATA: attribute = WITHINGS_MEASUREMENTS_MAP[measurement] - entity_id = await async_get_entity_id( - hass, attribute, person.user_id, SENSOR_DOMAIN - ) + entity_id = await async_get_entity_id(hass, attribute, USER_ID, SENSOR_DOMAIN) state_obj = hass.states.get(entity_id) - if attribute.entity_registry_enabled_default: - async_assert_state_equals(entity_id, state_obj, expected, attribute) - else: - assert state_obj is None - - # Unload - await component_factory.unload(PERSON0) + async_assert_state_equals(entity_id, state_obj, expected, attribute) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_all_entities( hass: HomeAssistant, - component_factory: ComponentFactory, - current_request_with_host: None, + snapshot: SnapshotAssertion, + withings: AsyncMock, + disable_webhook_delay, + config_entry: MockConfigEntry, ) -> None: """Test all entities.""" - entity_registry: EntityRegistry = er.async_get(hass) - - with patch( - "homeassistant.components.withings.sensor.BaseWithingsSensor.entity_registry_enabled_default" - ) as enabled_by_default_mock: - enabled_by_default_mock.return_value = True - - await component_factory.configure_component(profile_configs=(PERSON0,)) - - # Assert entities should not exist yet. - for attribute in SENSORS: - assert not await async_get_entity_id( - hass, attribute, PERSON0.user_id, SENSOR_DOMAIN - ) - - # person 0 - await component_factory.setup_profile(PERSON0.user_id) - - # Assert entities should exist. - for attribute in SENSORS: - entity_id = await async_get_entity_id( - hass, attribute, PERSON0.user_id, SENSOR_DOMAIN - ) - assert entity_id - assert entity_registry.async_is_registered(entity_id) - - resp = await component_factory.call_webhook(PERSON0.user_id, NotifyAppli.SLEEP) - assert resp.message_code == 0 - - resp = await component_factory.call_webhook(PERSON0.user_id, NotifyAppli.WEIGHT) - assert resp.message_code == 0 - - for person, measurement, expected in EXPECTED_DATA: - attribute = WITHINGS_MEASUREMENTS_MAP[measurement] - entity_id = await async_get_entity_id( - hass, attribute, person.user_id, SENSOR_DOMAIN - ) - state_obj = hass.states.get(entity_id) - - async_assert_state_equals(entity_id, state_obj, expected, attribute) + await setup_integration(hass, config_entry) - # Unload - await component_factory.unload(PERSON0) + for sensor in SENSORS: + entity_id = await async_get_entity_id(hass, sensor, USER_ID, SENSOR_DOMAIN) + assert hass.states.get(entity_id) == snapshot diff --git a/tests/components/wled/snapshots/test_select.ambr b/tests/components/wled/snapshots/test_select.ambr index 05d61fc18cb253..9cfc6c6e3febea 100644 --- a/tests/components/wled/snapshots/test_select.ambr +++ b/tests/components/wled/snapshots/test_select.ambr @@ -310,7 +310,7 @@ 'original_name': 'Playlist', 'platform': 'wled', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'playlist', 'unique_id': 'aabbccddee11_playlist', 'unit_of_measurement': None, }) @@ -393,7 +393,7 @@ 'original_name': 'Preset', 'platform': 'wled', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'preset', 'unique_id': 'aabbccddee11_preset', 'unit_of_measurement': None, }) diff --git a/tests/components/wled/snapshots/test_switch.ambr b/tests/components/wled/snapshots/test_switch.ambr index f89bde6ee17421..1434d2b2b2d7aa 100644 --- a/tests/components/wled/snapshots/test_switch.ambr +++ b/tests/components/wled/snapshots/test_switch.ambr @@ -40,7 +40,7 @@ 'original_name': 'Nightlight', 'platform': 'wled', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'nightlight', 'unique_id': 'aabbccddeeff_nightlight', 'unit_of_measurement': None, }) @@ -189,7 +189,7 @@ 'original_name': 'Sync receive', 'platform': 'wled', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'sync_receive', 'unique_id': 'aabbccddeeff_sync_receive', 'unit_of_measurement': None, }) @@ -264,7 +264,7 @@ 'original_name': 'Sync send', 'platform': 'wled', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'sync_send', 'unique_id': 'aabbccddeeff_sync_send', 'unit_of_measurement': None, }) diff --git a/tests/components/wled/test_config_flow.py b/tests/components/wled/test_config_flow.py index 9f99bd58615303..de01510adb370b 100644 --- a/tests/components/wled/test_config_flow.py +++ b/tests/components/wled/test_config_flow.py @@ -1,4 +1,5 @@ """Tests for the WLED config flow.""" +from ipaddress import ip_address from unittest.mock import AsyncMock, MagicMock import pytest @@ -44,8 +45,8 @@ async def test_full_zeroconf_flow_implementation(hass: HomeAssistant) -> None: DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.123", - addresses=["192.168.1.123"], + ip_address=ip_address("192.168.1.123"), + ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", name="mock_name", port=None, @@ -88,8 +89,8 @@ async def test_zeroconf_during_onboarding( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.123", - addresses=["192.168.1.123"], + ip_address=ip_address("192.168.1.123"), + ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", name="mock_name", port=None, @@ -133,8 +134,8 @@ async def test_zeroconf_connection_error( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.123", - addresses=["192.168.1.123"], + ip_address=ip_address("192.168.1.123"), + ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", name="mock_name", port=None, @@ -193,8 +194,8 @@ async def test_zeroconf_without_mac_device_exists_abort( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.123", - addresses=["192.168.1.123"], + ip_address=ip_address("192.168.1.123"), + ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", name="mock_name", port=None, @@ -218,8 +219,8 @@ async def test_zeroconf_with_mac_device_exists_abort( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.123", - addresses=["192.168.1.123"], + ip_address=ip_address("192.168.1.123"), + ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", name="mock_name", port=None, @@ -243,8 +244,8 @@ async def test_zeroconf_with_cct_channel_abort( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.123", - addresses=["192.168.1.123"], + ip_address=ip_address("192.168.1.123"), + ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", name="mock_name", port=None, diff --git a/tests/components/wled/test_light.py b/tests/components/wled/test_light.py index 678b4a44459f8a..ab8330293ba0fb 100644 --- a/tests/components/wled/test_light.py +++ b/tests/components/wled/test_light.py @@ -60,12 +60,12 @@ async def test_rgb_light_state( assert (entry := entity_registry.async_get("light.wled_rgb_light_segment_1")) assert entry.unique_id == "aabbccddeeff_1" - # Test master control of the lightstrip - assert (state := hass.states.get("light.wled_rgb_light_master")) + # Test main control of the lightstrip + assert (state := hass.states.get("light.wled_rgb_light_main")) assert state.attributes.get(ATTR_BRIGHTNESS) == 127 assert state.state == STATE_ON - assert (entry := entity_registry.async_get("light.wled_rgb_light_master")) + assert (entry := entity_registry.async_get("light.wled_rgb_light_main")) assert entry.unique_id == "aabbccddeeff" @@ -110,15 +110,15 @@ async def test_segment_change_state( ) -async def test_master_change_state( +async def test_main_change_state( hass: HomeAssistant, mock_wled: MagicMock, ) -> None: - """Test the change of state of the WLED master light control.""" + """Test the change of state of the WLED main light control.""" await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "light.wled_rgb_light_master", ATTR_TRANSITION: 5}, + {ATTR_ENTITY_ID: "light.wled_rgb_light_main", ATTR_TRANSITION: 5}, blocking=True, ) assert mock_wled.master.call_count == 1 @@ -132,7 +132,7 @@ async def test_master_change_state( SERVICE_TURN_ON, { ATTR_BRIGHTNESS: 42, - ATTR_ENTITY_ID: "light.wled_rgb_light_master", + ATTR_ENTITY_ID: "light.wled_rgb_light_main", ATTR_TRANSITION: 5, }, blocking=True, @@ -147,7 +147,7 @@ async def test_master_change_state( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "light.wled_rgb_light_master", ATTR_TRANSITION: 5}, + {ATTR_ENTITY_ID: "light.wled_rgb_light_main", ATTR_TRANSITION: 5}, blocking=True, ) assert mock_wled.master.call_count == 3 @@ -161,7 +161,7 @@ async def test_master_change_state( SERVICE_TURN_ON, { ATTR_BRIGHTNESS: 42, - ATTR_ENTITY_ID: "light.wled_rgb_light_master", + ATTR_ENTITY_ID: "light.wled_rgb_light_main", ATTR_TRANSITION: 5, }, blocking=True, @@ -183,7 +183,7 @@ async def test_dynamically_handle_segments( """Test if a new/deleted segment is dynamically added/removed.""" assert (segment0 := hass.states.get("light.wled_rgb_light")) assert segment0.state == STATE_ON - assert not hass.states.get("light.wled_rgb_light_master") + assert not hass.states.get("light.wled_rgb_light_main") assert not hass.states.get("light.wled_rgb_light_segment_1") return_value = mock_wled.update.return_value @@ -195,21 +195,21 @@ async def test_dynamically_handle_segments( async_fire_time_changed(hass) await hass.async_block_till_done() - assert (master := hass.states.get("light.wled_rgb_light_master")) - assert master.state == STATE_ON + assert (main := hass.states.get("light.wled_rgb_light_main")) + assert main.state == STATE_ON assert (segment0 := hass.states.get("light.wled_rgb_light")) assert segment0.state == STATE_ON assert (segment1 := hass.states.get("light.wled_rgb_light_segment_1")) assert segment1.state == STATE_ON - # Test adding if segment shows up again, including the master entity + # Test adding if segment shows up again, including the main entity mock_wled.update.return_value = return_value freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() - assert (master := hass.states.get("light.wled_rgb_light_master")) - assert master.state == STATE_UNAVAILABLE + assert (main := hass.states.get("light.wled_rgb_light_main")) + assert main.state == STATE_UNAVAILABLE assert (segment0 := hass.states.get("light.wled_rgb_light")) assert segment0.state == STATE_ON assert (segment1 := hass.states.get("light.wled_rgb_light_segment_1")) @@ -225,11 +225,11 @@ async def test_single_segment_behavior( """Test the behavior of the integration with a single segment.""" device = mock_wled.update.return_value - assert not hass.states.get("light.wled_rgb_light_master") + assert not hass.states.get("light.wled_rgb_light_main") assert (state := hass.states.get("light.wled_rgb_light")) assert state.state == STATE_ON - # Test segment brightness takes master into account + # Test segment brightness takes main into account device.state.brightness = 100 device.state.segments[0].brightness = 255 freezer.tick(SCAN_INTERVAL) @@ -239,7 +239,7 @@ async def test_single_segment_behavior( assert (state := hass.states.get("light.wled_rgb_light")) assert state.attributes.get(ATTR_BRIGHTNESS) == 100 - # Test segment is off when master is off + # Test segment is off when main is off device.state.on = False freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) @@ -248,7 +248,7 @@ async def test_single_segment_behavior( assert state assert state.state == STATE_OFF - # Test master is turned off when turning off a single segment + # Test main is turned off when turning off a single segment await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_OFF, @@ -261,7 +261,7 @@ async def test_single_segment_behavior( transition=50, ) - # Test master is turned on when turning on a single segment, and segment + # Test main is turned on when turning on a single segment, and segment # brightness is set to 255. await hass.services.async_call( LIGHT_DOMAIN, @@ -346,18 +346,18 @@ async def test_rgbw_light(hass: HomeAssistant, mock_wled: MagicMock) -> None: @pytest.mark.parametrize("device_fixture", ["rgb_single_segment"]) -async def test_single_segment_with_keep_master_light( +async def test_single_segment_with_keep_main_light( hass: HomeAssistant, init_integration: MockConfigEntry, mock_wled: MagicMock, ) -> None: """Test the behavior of the integration with a single segment.""" - assert not hass.states.get("light.wled_rgb_light_master") + assert not hass.states.get("light.wled_rgb_light_main") hass.config_entries.async_update_entry( init_integration, options={CONF_KEEP_MASTER_LIGHT: True} ) await hass.async_block_till_done() - assert (state := hass.states.get("light.wled_rgb_light_master")) + assert (state := hass.states.get("light.wled_rgb_light_main")) assert state.state == STATE_ON diff --git a/tests/components/xiaomi_aqara/test_config_flow.py b/tests/components/xiaomi_aqara/test_config_flow.py index 2f049a8662030a..d15a442a840220 100644 --- a/tests/components/xiaomi_aqara/test_config_flow.py +++ b/tests/components/xiaomi_aqara/test_config_flow.py @@ -1,4 +1,5 @@ """Test the Xiaomi Aqara config flow.""" +from ipaddress import ip_address from socket import gaierror from unittest.mock import Mock, patch @@ -403,8 +404,8 @@ async def test_zeroconf_success(hass: HomeAssistant) -> None: const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host=TEST_HOST, - addresses=[TEST_HOST], + ip_address=ip_address(TEST_HOST), + ip_addresses=[ip_address(TEST_HOST)], hostname="mock_hostname", name=TEST_ZEROCONF_NAME, port=None, @@ -450,8 +451,8 @@ async def test_zeroconf_missing_data(hass: HomeAssistant) -> None: const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host=TEST_HOST, - addresses=[TEST_HOST], + ip_address=ip_address(TEST_HOST), + ip_addresses=[ip_address(TEST_HOST)], hostname="mock_hostname", name=TEST_ZEROCONF_NAME, port=None, @@ -470,8 +471,8 @@ async def test_zeroconf_unknown_device(hass: HomeAssistant) -> None: const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host=TEST_HOST, - addresses=[TEST_HOST], + ip_address=ip_address(TEST_HOST), + ip_addresses=[ip_address(TEST_HOST)], hostname="mock_hostname", name="not-a-xiaomi-aqara-gateway", port=None, diff --git a/tests/components/xiaomi_miio/test_config_flow.py b/tests/components/xiaomi_miio/test_config_flow.py index 848bb7c8d9f624..a436908b44ff96 100644 --- a/tests/components/xiaomi_miio/test_config_flow.py +++ b/tests/components/xiaomi_miio/test_config_flow.py @@ -1,4 +1,5 @@ """Test the Xiaomi Miio config flow.""" +from ipaddress import ip_address from unittest.mock import Mock, patch from construct.core import ChecksumError @@ -426,8 +427,8 @@ async def test_zeroconf_gateway_success(hass: HomeAssistant) -> None: const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host=TEST_HOST, - addresses=[TEST_HOST], + ip_address=ip_address(TEST_HOST), + ip_addresses=[ip_address(TEST_HOST)], hostname="mock_hostname", name=TEST_ZEROCONF_NAME, port=None, @@ -469,8 +470,8 @@ async def test_zeroconf_unknown_device(hass: HomeAssistant) -> None: const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host=TEST_HOST, - addresses=[TEST_HOST], + ip_address=ip_address(TEST_HOST), + ip_addresses=[ip_address(TEST_HOST)], hostname="mock_hostname", name="not-a-xiaomi-miio-device", port=None, @@ -489,8 +490,8 @@ async def test_zeroconf_no_data(hass: HomeAssistant) -> None: const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host=None, - addresses=[], + ip_address=None, + ip_addresses=[], hostname="mock_hostname", name=None, port=None, @@ -509,8 +510,8 @@ async def test_zeroconf_missing_data(hass: HomeAssistant) -> None: const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host=TEST_HOST, - addresses=[TEST_HOST], + ip_address=ip_address(TEST_HOST), + ip_addresses=[ip_address(TEST_HOST)], hostname="mock_hostname", name=TEST_ZEROCONF_NAME, port=None, @@ -791,8 +792,8 @@ async def zeroconf_device_success(hass, zeroconf_name_to_test, model_to_test): const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host=TEST_HOST, - addresses=[TEST_HOST], + ip_address=ip_address(TEST_HOST), + ip_addresses=[ip_address(TEST_HOST)], hostname="mock_hostname", name=zeroconf_name_to_test, port=None, diff --git a/tests/components/yardian/__init__.py b/tests/components/yardian/__init__.py new file mode 100644 index 00000000000000..47f8cbc509e099 --- /dev/null +++ b/tests/components/yardian/__init__.py @@ -0,0 +1 @@ +"""Tests for the yardian integration.""" diff --git a/tests/components/yeelight/__init__.py b/tests/components/yeelight/__init__.py index d60ead707fb5d3..c7d279220f8766 100644 --- a/tests/components/yeelight/__init__.py +++ b/tests/components/yeelight/__init__.py @@ -1,5 +1,6 @@ """Tests for the Yeelight integration.""" from datetime import timedelta +from ipaddress import ip_address from unittest.mock import AsyncMock, MagicMock, patch from async_upnp_client.search import SsdpSearchListener @@ -42,8 +43,8 @@ ID_DECIMAL = f"{int(ID, 16):08d}" ZEROCONF_DATA = zeroconf.ZeroconfServiceInfo( - host=IP_ADDRESS, - addresses=[IP_ADDRESS], + ip_address=ip_address(IP_ADDRESS), + ip_addresses=[ip_address(IP_ADDRESS)], port=54321, hostname=f"yeelink-light-strip1_miio{ID_DECIMAL}.local.", type="_miio._udp.local.", diff --git a/tests/components/yeelight/test_config_flow.py b/tests/components/yeelight/test_config_flow.py index 8f46407aff6845..0bd5b5f59d0bd2 100644 --- a/tests/components/yeelight/test_config_flow.py +++ b/tests/components/yeelight/test_config_flow.py @@ -1,4 +1,5 @@ """Test the Yeelight config flow.""" +from ipaddress import ip_address from unittest.mock import patch import pytest @@ -465,8 +466,8 @@ async def test_discovered_by_homekit_and_dhcp(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, data=zeroconf.ZeroconfServiceInfo( - host=IP_ADDRESS, - addresses=[IP_ADDRESS], + ip_address=ip_address(IP_ADDRESS), + ip_addresses=[ip_address(IP_ADDRESS)], hostname="mock_hostname", name="mock_name", port=None, @@ -535,8 +536,8 @@ async def test_discovered_by_homekit_and_dhcp(hass: HomeAssistant) -> None: ( config_entries.SOURCE_HOMEKIT, zeroconf.ZeroconfServiceInfo( - host=IP_ADDRESS, - addresses=[IP_ADDRESS], + ip_address=ip_address(IP_ADDRESS), + ip_addresses=[ip_address(IP_ADDRESS)], hostname="mock_hostname", name="mock_name", port=None, @@ -603,8 +604,8 @@ async def test_discovered_by_dhcp_or_homekit(hass: HomeAssistant, source, data) ( config_entries.SOURCE_HOMEKIT, zeroconf.ZeroconfServiceInfo( - host=IP_ADDRESS, - addresses=[IP_ADDRESS], + ip_address=ip_address(IP_ADDRESS), + ip_addresses=[ip_address(IP_ADDRESS)], hostname="mock_hostname", name="mock_name", port=None, @@ -827,8 +828,8 @@ async def test_discovery_adds_missing_ip_id_only(hass: HomeAssistant) -> None: ( config_entries.SOURCE_HOMEKIT, zeroconf.ZeroconfServiceInfo( - host=IP_ADDRESS, - addresses=[IP_ADDRESS], + ip_address=ip_address(IP_ADDRESS), + ip_addresses=[ip_address(IP_ADDRESS)], hostname="mock_hostname", name="mock_name", port=None, diff --git a/tests/components/zeroconf/test_init.py b/tests/components/zeroconf/test_init.py index b07e2d5880a123..54406bb1b4d6a0 100644 --- a/tests/components/zeroconf/test_init.py +++ b/tests/components/zeroconf/test_init.py @@ -220,7 +220,7 @@ async def test_setup_with_overly_long_url_and_name( " string long string long string long string long string" ), ), patch( - "homeassistant.components.zeroconf.AsyncServiceInfo.request", + "homeassistant.components.zeroconf.AsyncServiceInfo.async_request", ): assert await async_setup_component(hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}}) hass.bus.async_fire(EVENT_HOMEASSISTANT_START) @@ -859,6 +859,7 @@ async def test_info_from_service_with_link_local_address_first( service_info.addresses = ["169.254.12.3", "192.168.66.12"] info = zeroconf.info_from_service(service_info) assert info.host == "192.168.66.12" + assert info.addresses == ["169.254.12.3", "192.168.66.12"] async def test_info_from_service_with_unspecified_address_first( @@ -870,6 +871,7 @@ async def test_info_from_service_with_unspecified_address_first( service_info.addresses = ["0.0.0.0", "192.168.66.12"] info = zeroconf.info_from_service(service_info) assert info.host == "192.168.66.12" + assert info.addresses == ["0.0.0.0", "192.168.66.12"] async def test_info_from_service_with_unspecified_address_only( @@ -892,6 +894,7 @@ async def test_info_from_service_with_link_local_address_second( service_info.addresses = ["192.168.66.12", "169.254.12.3"] info = zeroconf.info_from_service(service_info) assert info.host == "192.168.66.12" + assert info.addresses == ["192.168.66.12", "169.254.12.3"] async def test_info_from_service_with_link_local_address_only( @@ -1219,6 +1222,8 @@ async def test_setup_with_disallowed_characters_in_local_name( hass.config, "location_name", "My.House", + ), patch( + "homeassistant.components.zeroconf.AsyncServiceInfo.async_request", ): assert await async_setup_component(hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}}) hass.bus.async_fire(EVENT_HOMEASSISTANT_START) diff --git a/tests/components/zha/common.py b/tests/components/zha/common.py index db1da3721ee42f..44155d741b7492 100644 --- a/tests/components/zha/common.py +++ b/tests/components/zha/common.py @@ -9,7 +9,10 @@ import zigpy.zcl.foundation as zcl_f import homeassistant.components.zha.core.const as zha_const -from homeassistant.components.zha.core.helpers import async_get_zha_config_value +from homeassistant.components.zha.core.helpers import ( + async_get_zha_config_value, + get_zha_gateway, +) from homeassistant.helpers import entity_registry as er import homeassistant.util.dt as dt_util @@ -85,11 +88,6 @@ def update_attribute_cache(cluster): cluster.handle_message(hdr, msg) -def get_zha_gateway(hass): - """Return ZHA gateway from hass.data.""" - return hass.data[zha_const.DATA_ZHA][zha_const.DATA_ZHA_GATEWAY] - - def make_attribute(attrid, value, status=0): """Make an attribute.""" attr = zcl_f.Attribute() diff --git a/tests/components/zha/conftest.py b/tests/components/zha/conftest.py index 4778f3216da16b..e7dc7316f7328a 100644 --- a/tests/components/zha/conftest.py +++ b/tests/components/zha/conftest.py @@ -22,9 +22,10 @@ import homeassistant.components.zha.core.const as zha_const import homeassistant.components.zha.core.device as zha_core_device +from homeassistant.components.zha.core.helpers import get_zha_gateway from homeassistant.setup import async_setup_component -from . import common +from .common import patch_cluster as common_patch_cluster from tests.common import MockConfigEntry from tests.components.light.conftest import mock_light_profiles # noqa: F401 @@ -277,7 +278,7 @@ def _mock_dev( for cluster in itertools.chain( endpoint.in_clusters.values(), endpoint.out_clusters.values() ): - common.patch_cluster(cluster) + common_patch_cluster(cluster) if attributes is not None: for ep_id, clusters in attributes.items(): @@ -293,14 +294,20 @@ def _mock_dev( return _mock_dev +@patch("homeassistant.components.zha.setup_quirks", MagicMock(return_value=True)) @pytest.fixture def zha_device_joined(hass, setup_zha): """Return a newly joined ZHA device.""" + setup_zha_fixture = setup_zha - async def _zha_device(zigpy_dev): + async def _zha_device(zigpy_dev, *, setup_zha: bool = True): zigpy_dev.last_seen = time.time() - await setup_zha() - zha_gateway = common.get_zha_gateway(hass) + + if setup_zha: + await setup_zha_fixture() + + zha_gateway = get_zha_gateway(hass) + zha_gateway.application_controller.devices[zigpy_dev.ieee] = zigpy_dev await zha_gateway.async_device_initialized(zigpy_dev) await hass.async_block_till_done() return zha_gateway.get_device(zigpy_dev.ieee) @@ -308,18 +315,22 @@ async def _zha_device(zigpy_dev): return _zha_device +@patch("homeassistant.components.zha.setup_quirks", MagicMock(return_value=True)) @pytest.fixture def zha_device_restored(hass, zigpy_app_controller, setup_zha): """Return a restored ZHA device.""" + setup_zha_fixture = setup_zha - async def _zha_device(zigpy_dev, last_seen=None): + async def _zha_device(zigpy_dev, *, last_seen=None, setup_zha: bool = True): zigpy_app_controller.devices[zigpy_dev.ieee] = zigpy_dev if last_seen is not None: zigpy_dev.last_seen = last_seen - await setup_zha() - zha_gateway = hass.data[zha_const.DATA_ZHA][zha_const.DATA_ZHA_GATEWAY] + if setup_zha: + await setup_zha_fixture() + + zha_gateway = get_zha_gateway(hass) return zha_gateway.get_device(zigpy_dev.ieee) return _zha_device @@ -376,3 +387,10 @@ def hass_disable_services(hass): hass, "services", MagicMock(has_service=MagicMock(return_value=True)) ): yield hass + + +@pytest.fixture(autouse=True) +def speed_up_radio_mgr(): + """Speed up the radio manager connection time by removing delays.""" + with patch("homeassistant.components.zha.radio_manager.CONNECT_DELAY_S", 0.00001): + yield diff --git a/tests/components/zha/test_api.py b/tests/components/zha/test_api.py index c2cb16efcc8b67..89742fb1e49442 100644 --- a/tests/components/zha/test_api.py +++ b/tests/components/zha/test_api.py @@ -11,6 +11,7 @@ from homeassistant.components import zha from homeassistant.components.zha import api from homeassistant.components.zha.core.const import RadioType +from homeassistant.components.zha.core.helpers import get_zha_gateway from homeassistant.core import HomeAssistant if TYPE_CHECKING: @@ -40,7 +41,7 @@ async def test_async_get_network_settings_inactive( """Test reading settings with an inactive ZHA installation.""" await setup_zha() - gateway = api._get_gateway(hass) + gateway = get_zha_gateway(hass) await zha.async_unload_entry(hass, gateway.config_entry) backup = zigpy.backups.NetworkBackup() @@ -70,7 +71,7 @@ async def test_async_get_network_settings_missing( """Test reading settings with an inactive ZHA installation, no valid channel.""" await setup_zha() - gateway = api._get_gateway(hass) + gateway = get_zha_gateway(hass) await gateway.config_entry.async_unload(hass) # Network settings were never loaded for whatever reason diff --git a/tests/components/zha/test_cluster_handlers.py b/tests/components/zha/test_cluster_handlers.py index 7e0e8eaab85c7d..24162296cd504a 100644 --- a/tests/components/zha/test_cluster_handlers.py +++ b/tests/components/zha/test_cluster_handlers.py @@ -20,11 +20,12 @@ import homeassistant.components.zha.core.const as zha_const from homeassistant.components.zha.core.device import ZHADevice from homeassistant.components.zha.core.endpoint import Endpoint +from homeassistant.components.zha.core.helpers import get_zha_gateway import homeassistant.components.zha.core.registries as registries from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from .common import get_zha_gateway, make_zcl_header +from .common import make_zcl_header from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_TYPE from tests.common import async_capture_events diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index 77d8a615c722d2..9ec8048ea0339c 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -1,6 +1,7 @@ """Tests for ZHA config flow.""" import copy from datetime import timedelta +from ipaddress import ip_address import json from unittest.mock import AsyncMock, MagicMock, PropertyMock, create_autospec, patch import uuid @@ -10,6 +11,7 @@ from zigpy.backups import BackupManager import zigpy.config from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH +import zigpy.device from zigpy.exceptions import NetworkNotFormed import zigpy.types @@ -62,13 +64,6 @@ def mock_multipan_platform(): yield -@pytest.fixture(autouse=True) -def reduce_reconnect_timeout(): - """Reduces reconnect timeout to speed up tests.""" - with patch("homeassistant.components.zha.radio_manager.CONNECT_DELAY_S", 0.01): - yield - - @pytest.fixture(autouse=True) def mock_app(): """Mock zigpy app interface.""" @@ -148,8 +143,8 @@ def com_port(device="/dev/ttyUSB1234"): async def test_zeroconf_discovery_znp(hass: HomeAssistant) -> None: """Test zeroconf flow -- radio detected.""" service_info = zeroconf.ZeroconfServiceInfo( - host="192.168.1.200", - addresses=["192.168.1.200"], + ip_address=ip_address("192.168.1.200"), + ip_addresses=[ip_address("192.168.1.200")], hostname="tube._tube_zb_gw._tcp.local.", name="tube", port=6053, @@ -198,8 +193,8 @@ async def test_zeroconf_discovery_znp(hass: HomeAssistant) -> None: async def test_zigate_via_zeroconf(setup_entry_mock, hass: HomeAssistant) -> None: """Test zeroconf flow -- zigate radio detected.""" service_info = zeroconf.ZeroconfServiceInfo( - host="192.168.1.200", - addresses=["192.168.1.200"], + ip_address=ip_address("192.168.1.200"), + ip_addresses=[ip_address("192.168.1.200")], hostname="_zigate-zigbee-gateway._tcp.local.", name="any", port=1234, @@ -253,8 +248,8 @@ async def test_zigate_via_zeroconf(setup_entry_mock, hass: HomeAssistant) -> Non async def test_efr32_via_zeroconf(hass: HomeAssistant) -> None: """Test zeroconf flow -- efr32 radio detected.""" service_info = zeroconf.ZeroconfServiceInfo( - host="192.168.1.200", - addresses=["192.168.1.200"], + ip_address=ip_address("192.168.1.200"), + ip_addresses=[ip_address("192.168.1.200")], hostname="efr32._esphomelib._tcp.local.", name="efr32", port=1234, @@ -316,8 +311,8 @@ async def test_discovery_via_zeroconf_ip_change(hass: HomeAssistant) -> None: entry.add_to_hass(hass) service_info = zeroconf.ZeroconfServiceInfo( - host="192.168.1.22", - addresses=["192.168.1.22"], + ip_address=ip_address("192.168.1.22"), + ip_addresses=[ip_address("192.168.1.22")], hostname="tube_zb_gw_cc2652p2_poe.local.", name="mock_name", port=6053, @@ -349,8 +344,8 @@ async def test_discovery_via_zeroconf_ip_change_ignored(hass: HomeAssistant) -> entry.add_to_hass(hass) service_info = zeroconf.ZeroconfServiceInfo( - host="192.168.1.22", - addresses=["192.168.1.22"], + ip_address=ip_address("192.168.1.22"), + ip_addresses=[ip_address("192.168.1.22")], hostname="tube_zb_gw_cc2652p2_poe.local.", name="mock_name", port=6053, @@ -371,8 +366,8 @@ async def test_discovery_via_zeroconf_ip_change_ignored(hass: HomeAssistant) -> async def test_discovery_confirm_final_abort_if_entries(hass: HomeAssistant) -> None: """Test discovery aborts if ZHA was set up after the confirmation dialog is shown.""" service_info = zeroconf.ZeroconfServiceInfo( - host="192.168.1.200", - addresses=["192.168.1.200"], + ip_address=ip_address("192.168.1.200"), + ip_addresses=[ip_address("192.168.1.200")], hostname="tube._tube_zb_gw._tcp.local.", name="tube", port=6053, @@ -704,8 +699,8 @@ async def test_discovery_via_usb_zha_ignored_updates(hass: HomeAssistant) -> Non async def test_discovery_already_setup(hass: HomeAssistant) -> None: """Test zeroconf flow -- radio detected.""" service_info = zeroconf.ZeroconfServiceInfo( - host="192.168.1.200", - addresses=["192.168.1.200"], + ip_address=ip_address("192.168.1.200"), + ip_addresses=[ip_address("192.168.1.200")], hostname="_tube_zb_gw._tcp.local.", name="mock_name", port=6053, @@ -1181,6 +1176,7 @@ async def test_onboarding_auto_formation_new_hardware( ) -> None: """Test auto network formation with new hardware during onboarding.""" mock_app.load_network_info = AsyncMock(side_effect=NetworkNotFormed()) + mock_app.get_device = MagicMock(return_value=MagicMock(spec=zigpy.device.Device)) discovery_info = usb.UsbServiceInfo( device="/dev/ttyZIGBEE", pid="AAAA", diff --git a/tests/components/zha/test_device_action.py b/tests/components/zha/test_device_action.py index 31ffe9449e2b48..229fde89f15da6 100644 --- a/tests/components/zha/test_device_action.py +++ b/tests/components/zha/test_device_action.py @@ -108,21 +108,19 @@ async def test_get_actions(hass: HomeAssistant, device_ias) -> None: ieee_address = str(device_ias[0].ieee) - ha_device_registry = dr.async_get(hass) - reg_device = ha_device_registry.async_get_device( - identifiers={(DOMAIN, ieee_address)} - ) - ha_entity_registry = er.async_get(hass) - siren_level_select = ha_entity_registry.async_get( + device_registry = dr.async_get(hass) + reg_device = device_registry.async_get_device(identifiers={(DOMAIN, ieee_address)}) + entity_registry = er.async_get(hass) + siren_level_select = entity_registry.async_get( "select.fakemanufacturer_fakemodel_default_siren_level" ) - siren_tone_select = ha_entity_registry.async_get( + siren_tone_select = entity_registry.async_get( "select.fakemanufacturer_fakemodel_default_siren_tone" ) - strobe_level_select = ha_entity_registry.async_get( + strobe_level_select = entity_registry.async_get( "select.fakemanufacturer_fakemodel_default_strobe_level" ) - strobe_select = ha_entity_registry.async_get( + strobe_select = entity_registry.async_get( "select.fakemanufacturer_fakemodel_default_strobe" ) @@ -171,13 +169,13 @@ async def test_get_inovelli_actions(hass: HomeAssistant, device_inovelli) -> Non """Test we get the expected actions from a ZHA device.""" inovelli_ieee_address = str(device_inovelli[0].ieee) - ha_device_registry = dr.async_get(hass) - inovelli_reg_device = ha_device_registry.async_get_device( + device_registry = dr.async_get(hass) + inovelli_reg_device = device_registry.async_get_device( identifiers={(DOMAIN, inovelli_ieee_address)} ) - ha_entity_registry = er.async_get(hass) - inovelli_button = ha_entity_registry.async_get("button.inovelli_vzm31_sn_identify") - inovelli_light = ha_entity_registry.async_get("light.inovelli_vzm31_sn_light") + entity_registry = er.async_get(hass) + inovelli_button = entity_registry.async_get("button.inovelli_vzm31_sn_identify") + inovelli_light = entity_registry.async_get("light.inovelli_vzm31_sn_light") actions = await async_get_device_automations( hass, DeviceAutomationType.ACTION, inovelli_reg_device.id @@ -262,11 +260,9 @@ async def test_action(hass: HomeAssistant, device_ias, device_inovelli) -> None: ieee_address = str(zha_device.ieee) inovelli_ieee_address = str(inovelli_zha_device.ieee) - ha_device_registry = dr.async_get(hass) - reg_device = ha_device_registry.async_get_device( - identifiers={(DOMAIN, ieee_address)} - ) - inovelli_reg_device = ha_device_registry.async_get_device( + device_registry = dr.async_get(hass) + reg_device = device_registry.async_get_device(identifiers={(DOMAIN, ieee_address)}) + inovelli_reg_device = device_registry.async_get_device( identifiers={(DOMAIN, inovelli_ieee_address)} ) diff --git a/tests/components/zha/test_device_trigger.py b/tests/components/zha/test_device_trigger.py index 22f62cb977a871..096d83567fefed 100644 --- a/tests/components/zha/test_device_trigger.py +++ b/tests/components/zha/test_device_trigger.py @@ -9,6 +9,9 @@ import homeassistant.components.automation as automation from homeassistant.components.device_automation import DeviceAutomationType +from homeassistant.components.device_automation.exceptions import ( + InvalidDeviceAutomationConfig, +) from homeassistant.components.zha.core.const import ATTR_ENDPOINT_ID from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -20,6 +23,7 @@ from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE from tests.common import ( + MockConfigEntry, async_fire_time_changed, async_get_device_automations, async_mock_service, @@ -45,6 +49,16 @@ def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: LONG_RELEASE = "remote_button_long_release" +SWITCH_SIGNATURE = { + 1: { + SIG_EP_INPUT: [general.Basic.cluster_id], + SIG_EP_OUTPUT: [general.OnOff.cluster_id], + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.ON_OFF_SWITCH, + SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, + } +} + + @pytest.fixture(autouse=True) def sensor_platforms_only(): """Only set up the sensor platform and required base platforms to speed up tests.""" @@ -72,16 +86,7 @@ def calls(hass): async def mock_devices(hass, zigpy_device_mock, zha_device_joined_restored): """IAS device fixture.""" - zigpy_device = zigpy_device_mock( - { - 1: { - SIG_EP_INPUT: [general.Basic.cluster_id], - SIG_EP_OUTPUT: [general.OnOff.cluster_id], - SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.ON_OFF_SWITCH, - SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, - } - } - ) + zigpy_device = zigpy_device_mock(SWITCH_SIGNATURE) zha_device = await zha_device_joined_restored(zigpy_device) zha_device.update_available(True) @@ -397,3 +402,109 @@ async def test_exception_bad_trigger( "Unnamed automation failed to setup triggers and has been disabled: " "device does not have trigger ('junk', 'junk')" in caplog.text ) + + +async def test_validate_trigger_config_missing_info( + hass: HomeAssistant, + config_entry: MockConfigEntry, + zigpy_device_mock, + mock_zigpy_connect, + zha_device_joined, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test device triggers referring to a missing device.""" + + # Join a device + switch = zigpy_device_mock(SWITCH_SIGNATURE) + await zha_device_joined(switch) + + # After we unload the config entry, trigger info was not cached on startup, nor can + # it be pulled from the current device, making it impossible to validate triggers + await hass.config_entries.async_unload(config_entry.entry_id) + + ha_device_registry = dr.async_get(hass) + reg_device = ha_device_registry.async_get_device( + identifiers={("zha", str(switch.ieee))} + ) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "device_id": reg_device.id, + "domain": "zha", + "platform": "device", + "type": "junk", + "subtype": "junk", + }, + "action": { + "service": "test.automation", + "data": {"message": "service called"}, + }, + } + ] + }, + ) + + assert "Unable to get zha device" in caplog.text + + with pytest.raises(InvalidDeviceAutomationConfig): + await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, reg_device.id + ) + + +async def test_validate_trigger_config_unloaded_bad_info( + hass: HomeAssistant, + config_entry: MockConfigEntry, + zigpy_device_mock, + mock_zigpy_connect, + zha_device_joined, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test device triggers referring to a missing device.""" + + # Join a device + switch = zigpy_device_mock(SWITCH_SIGNATURE) + await zha_device_joined(switch) + + # After we unload the config entry, trigger info was not cached on startup, nor can + # it be pulled from the current device, making it impossible to validate triggers + await hass.config_entries.async_unload(config_entry.entry_id) + + # Reload ZHA to persist the device info in the cache + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + await hass.config_entries.async_unload(config_entry.entry_id) + + ha_device_registry = dr.async_get(hass) + reg_device = ha_device_registry.async_get_device( + identifiers={("zha", str(switch.ieee))} + ) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "device_id": reg_device.id, + "domain": "zha", + "platform": "device", + "type": "junk", + "subtype": "junk", + }, + "action": { + "service": "test.automation", + "data": {"message": "service called"}, + }, + } + ] + }, + ) + + assert "Unable to find trigger" in caplog.text diff --git a/tests/components/zha/test_diagnostics.py b/tests/components/zha/test_diagnostics.py index 6bcb321ab140bb..c13bb36c1c0e19 100644 --- a/tests/components/zha/test_diagnostics.py +++ b/tests/components/zha/test_diagnostics.py @@ -6,8 +6,8 @@ import zigpy.zcl.clusters.security as security from homeassistant.components.diagnostics import REDACTED -from homeassistant.components.zha.core.const import DATA_ZHA, DATA_ZHA_GATEWAY from homeassistant.components.zha.core.device import ZHADevice +from homeassistant.components.zha.core.helpers import get_zha_gateway from homeassistant.components.zha.diagnostics import KEYS_TO_REDACT from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -65,7 +65,7 @@ async def test_diagnostics_for_config_entry( """Test diagnostics for config entry.""" await zha_device_joined(zigpy_device) - gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + gateway = get_zha_gateway(hass) scan = {c: c for c in range(11, 26 + 1)} with patch.object(gateway.application_controller, "energy_scan", return_value=scan): diff --git a/tests/components/zha/test_discover.py b/tests/components/zha/test_discover.py index e0785601b4f4c4..768f974d9286d4 100644 --- a/tests/components/zha/test_discover.py +++ b/tests/components/zha/test_discover.py @@ -20,12 +20,12 @@ from homeassistant.components.zha.core.device import ZHADevice import homeassistant.components.zha.core.discovery as disc from homeassistant.components.zha.core.endpoint import Endpoint +from homeassistant.components.zha.core.helpers import get_zha_gateway import homeassistant.components.zha.core.registries as zha_regs from homeassistant.const import Platform from homeassistant.core import HomeAssistant import homeassistant.helpers.entity_registry as er -from .common import get_zha_gateway from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE from .zha_devices_list import ( DEV_SIG_ATTRIBUTES, diff --git a/tests/components/zha/test_fan.py b/tests/components/zha/test_fan.py index 3d0b065ab18a28..81ab1c2e0f5a32 100644 --- a/tests/components/zha/test_fan.py +++ b/tests/components/zha/test_fan.py @@ -21,6 +21,7 @@ from homeassistant.components.zha.core.device import ZHADevice from homeassistant.components.zha.core.discovery import GROUP_PROBE from homeassistant.components.zha.core.group import GroupMember +from homeassistant.components.zha.core.helpers import get_zha_gateway from homeassistant.components.zha.fan import ( PRESET_MODE_AUTO, PRESET_MODE_ON, @@ -45,7 +46,6 @@ async_test_rejoin, async_wait_for_updates, find_entity_id, - get_zha_gateway, send_attributes_report, ) from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE diff --git a/tests/components/zha/test_gateway.py b/tests/components/zha/test_gateway.py index 0f791a08955926..214bfcad9f0340 100644 --- a/tests/components/zha/test_gateway.py +++ b/tests/components/zha/test_gateway.py @@ -11,11 +11,12 @@ from homeassistant.components.zha.core.device import ZHADevice from homeassistant.components.zha.core.group import GroupMember +from homeassistant.components.zha.core.helpers import get_zha_gateway from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from .common import async_find_group_entity_id, get_zha_gateway +from .common import async_find_group_entity_id from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE IEEE_GROUPABLE_DEVICE = "01:2d:6f:00:0a:90:69:e8" diff --git a/tests/components/zha/test_init.py b/tests/components/zha/test_init.py index 24ee63fb3d5064..6bac012d667eb9 100644 --- a/tests/components/zha/test_init.py +++ b/tests/components/zha/test_init.py @@ -1,23 +1,27 @@ """Tests for ZHA integration init.""" +import asyncio from unittest.mock import AsyncMock, Mock, patch import pytest from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH +from zigpy.exceptions import TransientConnectionError -from homeassistant.components.zha import async_setup_entry from homeassistant.components.zha.core.const import ( CONF_BAUDRATE, CONF_RADIO_TYPE, CONF_USB_PATH, DOMAIN, ) -from homeassistant.const import MAJOR_VERSION, MINOR_VERSION +from homeassistant.const import MAJOR_VERSION, MINOR_VERSION, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers.event import async_call_later from homeassistant.setup import async_setup_component +from .test_light import LIGHT_ON_OFF + from tests.common import MockConfigEntry -DATA_RADIO_TYPE = "deconz" +DATA_RADIO_TYPE = "ezsp" DATA_PORT_PATH = "/dev/serial/by-id/FTDI_USB__-__Serial_Cable_12345678-if00-port0" @@ -132,7 +136,7 @@ async def test_config_depreciation(hass: HomeAssistant, zha_config) -> None: "homeassistant.components.zha.websocket_api.async_load_api", Mock(return_value=True) ) async def test_setup_with_v3_cleaning_uri( - hass: HomeAssistant, path: str, cleaned_path: str + hass: HomeAssistant, path: str, cleaned_path: str, mock_zigpy_connect ) -> None: """Test migration of config entry from v3, applying corrections to the port path.""" config_entry_v3 = MockConfigEntry( @@ -145,15 +149,53 @@ async def test_setup_with_v3_cleaning_uri( ) config_entry_v3.add_to_hass(hass) - with patch( - "homeassistant.components.zha.ZHAGateway", return_value=AsyncMock() - ) as mock_gateway: - mock_gateway.return_value.coordinator_ieee = "mock_ieee" - mock_gateway.return_value.radio_description = "mock_radio" - - assert await async_setup_entry(hass, config_entry_v3) - hass.data[DOMAIN]["zha_gateway"] = mock_gateway.return_value + await hass.config_entries.async_setup(config_entry_v3.entry_id) + await hass.async_block_till_done() + await hass.config_entries.async_unload(config_entry_v3.entry_id) assert config_entry_v3.data[CONF_RADIO_TYPE] == DATA_RADIO_TYPE assert config_entry_v3.data[CONF_DEVICE][CONF_DEVICE_PATH] == cleaned_path assert config_entry_v3.version == 3 + + +@patch( + "homeassistant.components.zha.PLATFORMS", + [Platform.LIGHT, Platform.BUTTON, Platform.SENSOR, Platform.SELECT], +) +async def test_zha_retry_unique_ids( + hass: HomeAssistant, + config_entry: MockConfigEntry, + zigpy_device_mock, + mock_zigpy_connect, + caplog, +) -> None: + """Test that ZHA retrying creates unique entity IDs.""" + + config_entry.add_to_hass(hass) + + # Ensure we have some device to try to load + app = mock_zigpy_connect.return_value + light = zigpy_device_mock(LIGHT_ON_OFF) + app.devices[light.ieee] = light + + # Re-try setup but have it fail once, so entities have two chances to be created + with patch.object( + app, + "startup", + side_effect=[TransientConnectionError(), None], + ) as mock_connect: + with patch( + "homeassistant.config_entries.async_call_later", + lambda hass, delay, action: async_call_later(hass, 0, action), + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Wait for the config entry setup to retry + await asyncio.sleep(0.1) + + assert len(mock_connect.mock_calls) == 2 + + await hass.config_entries.async_unload(config_entry.entry_id) + + assert "does not generate unique IDs" not in caplog.text diff --git a/tests/components/zha/test_light.py b/tests/components/zha/test_light.py index c1f5cf04e35727..da91340b864e09 100644 --- a/tests/components/zha/test_light.py +++ b/tests/components/zha/test_light.py @@ -20,9 +20,11 @@ ZHA_OPTIONS, ) from homeassistant.components.zha.core.group import GroupMember +from homeassistant.components.zha.core.helpers import get_zha_gateway from homeassistant.components.zha.light import FLASH_EFFECTS from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er import homeassistant.util.dt as dt_util from .common import ( @@ -32,7 +34,6 @@ async_test_rejoin, async_wait_for_updates, find_entity_id, - get_zha_gateway, patch_zha_config, send_attributes_report, update_attribute_cache, @@ -1781,7 +1782,8 @@ async def test_zha_group_light_entity( assert device_3_entity_id not in zha_group.member_entity_ids # make sure the entity registry entry is still there - assert zha_gateway.ha_entity_registry.async_get(group_entity_id) is not None + entity_registry = er.async_get(hass) + assert entity_registry.async_get(group_entity_id) is not None # add a member back and ensure that the group entity was created again await zha_group.async_add_members([GroupMember(device_light_3.ieee, 1)]) @@ -1811,10 +1813,10 @@ async def test_zha_group_light_entity( assert len(zha_group.members) == 3 # remove the group and ensure that there is no entity and that the entity registry is cleaned up - assert zha_gateway.ha_entity_registry.async_get(group_entity_id) is not None + assert entity_registry.async_get(group_entity_id) is not None await zha_gateway.async_remove_zigpy_group(zha_group.group_id) assert hass.states.get(group_entity_id) is None - assert zha_gateway.ha_entity_registry.async_get(group_entity_id) is None + assert entity_registry.async_get(group_entity_id) is None @patch( @@ -1914,7 +1916,8 @@ async def test_group_member_assume_state( assert hass.states.get(group_entity_id).state == STATE_OFF # remove the group and ensure that there is no entity and that the entity registry is cleaned up - assert zha_gateway.ha_entity_registry.async_get(group_entity_id) is not None + entity_registry = er.async_get(hass) + assert entity_registry.async_get(group_entity_id) is not None await zha_gateway.async_remove_zigpy_group(zha_group.group_id) assert hass.states.get(group_entity_id) is None - assert zha_gateway.ha_entity_registry.async_get(group_entity_id) is None + assert entity_registry.async_get(group_entity_id) is None diff --git a/tests/components/zha/test_radio_manager.py b/tests/components/zha/test_radio_manager.py index 7acf9219d67498..1467e2e2951de6 100644 --- a/tests/components/zha/test_radio_manager.py +++ b/tests/components/zha/test_radio_manager.py @@ -32,9 +32,7 @@ def disable_platform_only(): @pytest.fixture(autouse=True) def reduce_reconnect_timeout(): """Reduces reconnect timeout to speed up tests.""" - with patch( - "homeassistant.components.zha.radio_manager.CONNECT_DELAY_S", 0.0001 - ), patch("homeassistant.components.zha.radio_manager.RETRY_DELAY_S", 0.0001): + with patch("homeassistant.components.zha.radio_manager.RETRY_DELAY_S", 0.0001): yield @@ -99,7 +97,7 @@ def mock_connect_zigpy_app() -> Generator[MagicMock, None, None]: ) with patch( - "homeassistant.components.zha.radio_manager.ZhaRadioManager._connect_zigpy_app", + "homeassistant.components.zha.radio_manager.ZhaRadioManager.connect_zigpy_app", return_value=mock_connect_app, ): yield mock_connect_app diff --git a/tests/components/zha/test_silabs_multiprotocol.py b/tests/components/zha/test_silabs_multiprotocol.py index beae0230901e59..4d11ae81b089e0 100644 --- a/tests/components/zha/test_silabs_multiprotocol.py +++ b/tests/components/zha/test_silabs_multiprotocol.py @@ -9,7 +9,8 @@ import zigpy.state from homeassistant.components import zha -from homeassistant.components.zha import api, silabs_multiprotocol +from homeassistant.components.zha import silabs_multiprotocol +from homeassistant.components.zha.core.helpers import get_zha_gateway from homeassistant.core import HomeAssistant if TYPE_CHECKING: @@ -36,7 +37,7 @@ async def test_async_get_channel_missing( """Test reading channel with an inactive ZHA installation, no valid channel.""" await setup_zha() - gateway = api._get_gateway(hass) + gateway = get_zha_gateway(hass) await zha.async_unload_entry(hass, gateway.config_entry) # Network settings were never loaded for whatever reason diff --git a/tests/components/zha/test_switch.py b/tests/components/zha/test_switch.py index fe7450eff67c94..b07b34763d10d5 100644 --- a/tests/components/zha/test_switch.py +++ b/tests/components/zha/test_switch.py @@ -19,6 +19,7 @@ from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.components.zha.core.group import GroupMember +from homeassistant.components.zha.core.helpers import get_zha_gateway from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -30,7 +31,6 @@ async_test_rejoin, async_wait_for_updates, find_entity_id, - get_zha_gateway, send_attributes_report, ) from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_TYPE diff --git a/tests/components/zha/test_websocket_api.py b/tests/components/zha/test_websocket_api.py index 740ffd6c06c6ab..b0e15a013189f0 100644 --- a/tests/components/zha/test_websocket_api.py +++ b/tests/components/zha/test_websocket_api.py @@ -940,6 +940,7 @@ async def test_websocket_bind_unbind_devices( @pytest.mark.parametrize("command_type", ["bind", "unbind"]) async def test_websocket_bind_unbind_group( command_type: str, + hass: HomeAssistant, app_controller: ControllerApplication, zha_client, ) -> None: @@ -947,8 +948,9 @@ async def test_websocket_bind_unbind_group( test_group_id = 0x0001 gateway_mock = MagicMock() + with patch( - "homeassistant.components.zha.websocket_api.get_gateway", + "homeassistant.components.zha.websocket_api.get_zha_gateway", return_value=gateway_mock, ): device_mock = MagicMock() diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index dcd847a6e12dc3..e950ff0402c866 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -650,6 +650,12 @@ def nice_ibt4zwave_state_fixture(): return json.loads(load_fixture("zwave_js/cover_nice_ibt4zwave_state.json")) +@pytest.fixture(name="logic_group_zdb5100_state", scope="session") +def logic_group_zdb5100_state_fixture(): + """Load the Logic Group ZDB5100 node state fixture data.""" + return json.loads(load_fixture("zwave_js/logic_group_zdb5100_state.json")) + + # model fixtures @@ -1262,3 +1268,11 @@ def nice_ibt4zwave_fixture(client, nice_ibt4zwave_state): node = Node(client, copy.deepcopy(nice_ibt4zwave_state)) client.driver.controller.nodes[node.node_id] = node return node + + +@pytest.fixture(name="logic_group_zdb5100") +def logic_group_zdb5100_fixture(client, logic_group_zdb5100_state): + """Mock a ZDB5100 light node.""" + node = Node(client, copy.deepcopy(logic_group_zdb5100_state)) + client.driver.controller.nodes[node.node_id] = node + return node diff --git a/tests/components/zwave_js/fixtures/logic_group_zdb5100_state.json b/tests/components/zwave_js/fixtures/logic_group_zdb5100_state.json new file mode 100644 index 00000000000000..b570e9cea34909 --- /dev/null +++ b/tests/components/zwave_js/fixtures/logic_group_zdb5100_state.json @@ -0,0 +1,4691 @@ +{ + "nodeId": 116, + "index": 0, + "installerIcon": 5632, + "userIcon": 5632, + "status": 4, + "ready": true, + "isListening": true, + "isRouting": true, + "isSecure": false, + "manufacturerId": 564, + "productId": 289, + "productType": 3, + "firmwareVersion": "1.8.0", + "zwavePlusVersion": 1, + "name": "matrix_office", + "location": "**REDACTED**", + "deviceConfig": { + "filename": "/usr/src/app/store/config/zdb5100.json", + "isEmbedded": false, + "manufacturer": "Logic Group", + "manufacturerId": 564, + "label": "ZDB5100", + "description": "Wall Controller", + "devices": [ + { + "productType": 3, + "productId": 289 + } + ], + "firmwareVersion": { + "min": "0.0", + "max": "255.255" + }, + "preferred": false, + "endpoints": {}, + "paramInformation": { + "_map": {} + }, + "compat": { + "disableBasicMapping": true + }, + "metadata": { + "inclusion": "Remove white button cover and press on the center switch with a non-conductive object. The LEDs will now start blinking on button 1 (upper left button)", + "exclusion": "Remove white button cover and press on the center switch with a non-conductive object. The LEDs will now start blinking on button 1 (upper left button)", + "reset": "Remove white button cover and long-press the center switch for 10 seconds with a non-conductive object. Please use this procedure only when the network primary controller is missing or otherwise inoperable", + "manual": "https://products.z-wavealliance.org/ProductManual/File?folder=&filename=MarketCertificationFiles/3399/MATRIX_ZDB5100_User_Manual_1_01-EN.pdf" + } + }, + "label": "ZDB5100", + "endpointCountIsDynamic": false, + "endpointsHaveIdenticalCapabilities": false, + "individualEndpointCount": 5, + "aggregatedEndpointCount": 0, + "interviewAttempts": 1, + "endpoints": [ + { + "nodeId": 116, + "index": 0, + "installerIcon": 5632, + "userIcon": 5632, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 24, + "label": "Wall Controller" + }, + "specific": { + "key": 1, + "label": "Basic Wall Controller" + }, + "mandatorySupportedCCs": [], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 1, + "isSecure": false + }, + { + "id": 85, + "name": "Transport Service", + "version": 2, + "isSecure": false + }, + { + "id": 134, + "name": "Version", + "version": 3, + "isSecure": false + }, + { + "id": 114, + "name": "Manufacturer Specific", + "version": 2, + "isSecure": false + }, + { + "id": 90, + "name": "Device Reset Locally", + "version": 1, + "isSecure": false + }, + { + "id": 115, + "name": "Powerlevel", + "version": 1, + "isSecure": false + }, + { + "id": 152, + "name": "Security", + "version": 1, + "isSecure": true + }, + { + "id": 159, + "name": "Security 2", + "version": 1, + "isSecure": true + }, + { + "id": 96, + "name": "Multi Channel", + "version": 4, + "isSecure": false + }, + { + "id": 112, + "name": "Configuration", + "version": 3, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 122, + "name": "Firmware Update Meta Data", + "version": 4, + "isSecure": false + }, + { + "id": 91, + "name": "Central Scene", + "version": 3, + "isSecure": false + }, + { + "id": 37, + "name": "Binary Switch", + "version": 1, + "isSecure": false + }, + { + "id": 51, + "name": "Color Switch", + "version": 1, + "isSecure": false + } + ] + }, + { + "nodeId": 116, + "index": 1, + "installerIcon": 7168, + "userIcon": 7172, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 24, + "label": "Wall Controller" + }, + "specific": { + "key": 1, + "label": "Basic Wall Controller" + }, + "mandatorySupportedCCs": [], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 1, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 37, + "name": "Binary Switch", + "version": 1, + "isSecure": false + }, + { + "id": 51, + "name": "Color Switch", + "version": 1, + "isSecure": false + } + ] + }, + { + "nodeId": 116, + "index": 2, + "installerIcon": 7168, + "userIcon": 7172, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 24, + "label": "Wall Controller" + }, + "specific": { + "key": 1, + "label": "Basic Wall Controller" + }, + "mandatorySupportedCCs": [], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 1, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 37, + "name": "Binary Switch", + "version": 1, + "isSecure": false + }, + { + "id": 51, + "name": "Color Switch", + "version": 1, + "isSecure": false + } + ] + }, + { + "nodeId": 116, + "index": 3, + "installerIcon": 7168, + "userIcon": 7172, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 24, + "label": "Wall Controller" + }, + "specific": { + "key": 1, + "label": "Basic Wall Controller" + }, + "mandatorySupportedCCs": [], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 1, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 37, + "name": "Binary Switch", + "version": 1, + "isSecure": false + }, + { + "id": 51, + "name": "Color Switch", + "version": 1, + "isSecure": false + } + ] + }, + { + "nodeId": 116, + "index": 4, + "installerIcon": 7168, + "userIcon": 7172, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 24, + "label": "Wall Controller" + }, + "specific": { + "key": 1, + "label": "Basic Wall Controller" + }, + "mandatorySupportedCCs": [], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 1, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 37, + "name": "Binary Switch", + "version": 1, + "isSecure": false + }, + { + "id": 51, + "name": "Color Switch", + "version": 1, + "isSecure": false + } + ] + }, + { + "nodeId": 116, + "index": 5, + "installerIcon": 1536, + "userIcon": 1537, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 1, + "label": "Multilevel Power Switch" + }, + "mandatorySupportedCCs": [32, 38, 39], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 32, + "name": "Basic", + "version": 2, + "isSecure": false + }, + { + "id": 38, + "name": "Multilevel Switch", + "version": 4, + "isSecure": false + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 1, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 37, + "name": "Binary Switch", + "version": 1, + "isSecure": false + } + ] + } + ], + "values": [ + { + "endpoint": 0, + "commandClass": 91, + "commandClassName": "Central Scene", + "property": "scene", + "propertyKey": "001", + "propertyName": "scene", + "propertyKeyName": "001", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Scene 001", + "min": 0, + "max": 255, + "states": { + "0": "KeyPressed", + "1": "KeyReleased", + "2": "KeyHeldDown", + "3": "KeyPressed2x", + "4": "KeyPressed3x" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 91, + "commandClassName": "Central Scene", + "property": "scene", + "propertyKey": "002", + "propertyName": "scene", + "propertyKeyName": "002", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Scene 002", + "min": 0, + "max": 255, + "states": { + "0": "KeyPressed", + "1": "KeyReleased", + "2": "KeyHeldDown", + "3": "KeyPressed2x", + "4": "KeyPressed3x" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 91, + "commandClassName": "Central Scene", + "property": "scene", + "propertyKey": "003", + "propertyName": "scene", + "propertyKeyName": "003", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Scene 003", + "min": 0, + "max": 255, + "states": { + "0": "KeyPressed", + "1": "KeyReleased", + "2": "KeyHeldDown", + "3": "KeyPressed2x", + "4": "KeyPressed3x" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 91, + "commandClassName": "Central Scene", + "property": "scene", + "propertyKey": "004", + "propertyName": "scene", + "propertyKeyName": "004", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Scene 004", + "min": 0, + "max": 255, + "states": { + "0": "KeyPressed", + "1": "KeyReleased", + "2": "KeyHeldDown", + "3": "KeyPressed2x", + "4": "KeyPressed3x" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 91, + "commandClassName": "Central Scene", + "property": "slowRefresh", + "propertyName": "slowRefresh", + "ccVersion": 3, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "description": "When this is true, KeyHeldDown notifications are sent every 55s. When this is false, the notifications are sent every 200ms.", + "label": "Send held down notifications at a slow rate", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 1, + "propertyKey": 1, + "propertyName": "Button 1", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 1", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Button 1" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 1, + "propertyKey": 2, + "propertyName": "Button 2", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 2", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Button 2" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 1, + "propertyKey": 4, + "propertyName": "Button 3", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 3", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Button 3" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 1, + "propertyKey": 8, + "propertyName": "Button 4", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 4", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Button 4" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 2, + "propertyName": "Duration of Dimming", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Duration of Dimming", + "default": 5, + "min": 0, + "max": 255, + "unit": "seconds", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Duration of Dimming" + }, + "value": 5 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 3, + "propertyName": "Duration of On/Off", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Duration of On/Off", + "default": 0, + "min": 0, + "max": 255, + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Duration of On/Off" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 4, + "propertyName": "Dimmer Mode", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Dimmer Mode", + "default": 1, + "min": 0, + "max": 2, + "states": { + "0": "Switch only", + "1": "Trailing edge", + "2": "Leading edge" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Dimmer Mode" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 5, + "propertyName": "Dimmer: Minimum Level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Dimmer: Minimum Level", + "default": 0, + "min": 0, + "max": 99, + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Dimmer: Minimum Level" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 6, + "propertyName": "Dimmer: Maximum Level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Dimmer: Maximum Level", + "default": 99, + "min": 0, + "max": 99, + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Dimmer: Maximum Level" + }, + "value": 99 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 7, + "propertyName": "Central Scene", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Central Scene", + "default": 1, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Central Scene" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 8, + "propertyName": "Double Press", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Double Press", + "default": 1, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Double Press" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 10, + "propertyName": "Enhanced LED Control", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Enhanced LED Control", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Enhanced LED Control" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 11, + "propertyName": "Button Debounce Timer", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button Debounce Timer", + "default": 5, + "min": 1, + "max": 255, + "unit": "10 ms", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button Debounce Timer" + }, + "value": 5 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 12, + "propertyName": "Button Press Threshold Time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button Press Threshold Time", + "default": 20, + "min": 1, + "max": 255, + "unit": "10 ms", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button Press Threshold Time" + }, + "value": 20 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 13, + "propertyName": "Button Held Threshold Time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button Held Threshold Time", + "default": 50, + "min": 1, + "max": 255, + "unit": "10 ms", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button Held Threshold Time" + }, + "value": 50 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 14, + "propertyKey": 4278190080, + "propertyName": "LED Indicator Brightness: Red", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "LED Indicator Brightness: Red", + "default": 255, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "LED Indicator Brightness: Red" + }, + "value": 255 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 14, + "propertyKey": 16711680, + "propertyName": "LED Indicator Brightness: Green", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "LED Indicator Brightness: Green", + "default": 255, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "LED Indicator Brightness: Green" + }, + "value": 255 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 14, + "propertyKey": 65280, + "propertyName": "LED Indicator Brightness: Blue", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "LED Indicator Brightness: Blue", + "default": 255, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "LED Indicator Brightness: Blue" + }, + "value": 255 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 15, + "propertyKey": 1, + "propertyName": "Send Association Group 2 Messages Securely", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Send Association Group 2 Messages Securely", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 2, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Send Association Group 2 Messages Securely" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 15, + "propertyKey": 2, + "propertyName": "Send Association Group 3 Messages Securely", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Send Association Group 3 Messages Securely", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 2, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Send Association Group 3 Messages Securely" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 15, + "propertyKey": 4, + "propertyName": "Send Association Group 4 Messages Securely", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Send Association Group 4 Messages Securely", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 2, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Send Association Group 4 Messages Securely" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 15, + "propertyKey": 8, + "propertyName": "Send Association Group 5 Messages Securely", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Send Association Group 5 Messages Securely", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 2, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Send Association Group 5 Messages Securely" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 15, + "propertyKey": 16, + "propertyName": "Send Association Group 6 Messages Securely", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Send Association Group 6 Messages Securely", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 2, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Send Association Group 6 Messages Securely" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 15, + "propertyKey": 32, + "propertyName": "Send Association Group 7 Messages Securely", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Send Association Group 7 Messages Securely", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 2, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Send Association Group 7 Messages Securely" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 15, + "propertyKey": 64, + "propertyName": "Send Association Group 8 Messages Securely", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Send Association Group 8 Messages Securely", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 2, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Send Association Group 8 Messages Securely" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 15, + "propertyKey": 128, + "propertyName": "Send Association Group 9 Messages Securely", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Send Association Group 9 Messages Securely", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 2, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Send Association Group 9 Messages Securely" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 15, + "propertyKey": 256, + "propertyName": "Send Association Group 10 Messages Securely", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Send Association Group 10 Messages Securely", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 2, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Send Association Group 10 Messages Securely" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 15, + "propertyKey": 512, + "propertyName": "Send Association Group 11 Messages Securely", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Send Association Group 11 Messages Securely", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 2, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Send Association Group 11 Messages Securely" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 15, + "propertyKey": 1024, + "propertyName": "Send Association Group 12 Messages Securely", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Send Association Group 12 Messages Securely", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 2, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Send Association Group 12 Messages Securely" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 15, + "propertyKey": 2048, + "propertyName": "Send Association Group 13 Messages Securely", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Send Association Group 13 Messages Securely", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 2, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Send Association Group 13 Messages Securely" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 15, + "propertyKey": 4096, + "propertyName": "Send Association Group 14 Messages Securely", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Send Association Group 14 Messages Securely", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 2, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Send Association Group 14 Messages Securely" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 16, + "propertyName": "Button 1 Functionality", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 1 Functionality", + "default": 0, + "min": 0, + "max": 4, + "states": { + "0": "Toggle", + "1": "Automatic turn off after time expired", + "2": "Automatic turn on after time expired", + "3": "Always turn off or dim down", + "4": "Always turn on or dim up" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Button 1 Functionality" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 17, + "propertyName": "Button 1 - Timer", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 1 - Timer", + "default": 300, + "min": 0, + "max": 43200, + "unit": "seconds", + "valueSize": 2, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 1 - Timer" + }, + "value": 300 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 18, + "propertyKey": 4278190080, + "propertyName": "Button 1 - Single Press", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 1 - Single Press", + "default": 1, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Button 1 - Single Press" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 18, + "propertyKey": 16711680, + "propertyName": "Button 1 - Single Press (On Value)", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 1 - Single Press (On Value)", + "default": 255, + "min": 0, + "max": 99, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 1 - Single Press (On Value)" + }, + "value": 255 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 18, + "propertyKey": 65280, + "propertyName": "Button 1 - Single Press (Off Value)", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 1 - Single Press (Off Value)", + "default": 0, + "min": 0, + "max": 99, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 1 - Single Press (Off Value)" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 19, + "propertyName": "Button 1 - Binary Switch Support", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 1 - Binary Switch Support", + "default": 0, + "min": 0, + "max": 2, + "states": { + "0": "LED", + "1": "Switch and LED", + "2": "Button activated" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Button 1 - Binary Switch Support" + }, + "value": 2 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 20, + "propertyName": "Button 1 LED Indicator", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 1 LED Indicator", + "default": 7, + "min": 0, + "max": 7, + "states": { + "0": "Disable", + "1": "Follow switch", + "2": "Follow switch - inverted", + "5": "Follow internal dimmer", + "6": "Follow internal dimmer - inverted", + "7": "On for 5 seconds" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Button 1 LED Indicator" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 21, + "propertyName": "Button 1 LED Indicator Color Commands", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 1 LED Indicator Color Commands", + "default": 0, + "min": 0, + "max": 2, + "states": { + "0": "Direct control", + "1": "Color for off state", + "2": "Color for on state" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Button 1 LED Indicator Color Commands" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 22, + "propertyKey": 4278190080, + "propertyName": "Button 1 LED Indicator (On): Red", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 1 LED Indicator (On): Red", + "default": 0, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 1 LED Indicator (On): Red" + }, + "value": 255 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 22, + "propertyKey": 16711680, + "propertyName": "Button 1 LED Indicator (On): Green", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 1 LED Indicator (On): Green", + "default": 0, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 1 LED Indicator (On): Green" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 22, + "propertyKey": 65280, + "propertyName": "Button 1 LED Indicator (On): Blue", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 1 LED Indicator (On): Blue", + "default": 127, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 1 LED Indicator (On): Blue" + }, + "value": 234 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 22, + "propertyKey": 255, + "propertyName": "LED Time For Button 1 (On): Blinking", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "LED Time For Button 1 (On): Blinking", + "default": 0, + "min": 0, + "max": 255, + "unit": "100 ms", + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "LED Time For Button 1 (On): Blinking" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 4278190080, + "propertyName": "Button 1 LED Indicator (Off): Red", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 1 LED Indicator (Off): Red", + "default": 47, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 1 LED Indicator (Off): Red" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 16711680, + "propertyName": "Button 1 LED Indicator (Off): Green", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 1 LED Indicator (Off): Green", + "default": 47, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 1 LED Indicator (Off): Green" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 65280, + "propertyName": "Button 1 LED Indicator (Off): Blue", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 1 LED Indicator (Off): Blue", + "default": 47, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 1 LED Indicator (Off): Blue" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 255, + "propertyName": "LED Time For Button 1 (Off): Blinking", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "LED Time For Button 1 (Off): Blinking", + "default": 0, + "min": 0, + "max": 255, + "unit": "100 ms", + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "LED Time For Button 1 (Off): Blinking" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 24, + "propertyName": "Button 2 Functionality", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 2 Functionality", + "default": 0, + "min": 0, + "max": 4, + "states": { + "0": "Toggle", + "1": "Automatic turn off after time expired", + "2": "Automatic turn on after time expired", + "3": "Always turn off or dim down", + "4": "Always turn on or dim up" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Button 2 Functionality" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 25, + "propertyName": "Button 2 - Timer", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 2 - Timer", + "default": 300, + "min": 0, + "max": 43200, + "unit": "seconds", + "valueSize": 2, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 2 - Timer" + }, + "value": 300 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 26, + "propertyKey": 4278190080, + "propertyName": "Button 2 - Single Press", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 2 - Single Press", + "default": 1, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Button 2 - Single Press" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 26, + "propertyKey": 16711680, + "propertyName": "Button 2 - Single Press (On Value)", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 2 - Single Press (On Value)", + "default": 255, + "min": 0, + "max": 99, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 2 - Single Press (On Value)" + }, + "value": 255 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 26, + "propertyKey": 65280, + "propertyName": "Button 2 - Single Press (Off Value)", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 2 - Single Press (Off Value)", + "default": 0, + "min": 0, + "max": 99, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 2 - Single Press (Off Value)" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 27, + "propertyName": "Button 2 - Binary Switch Support", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 2 - Binary Switch Support", + "default": 0, + "min": 0, + "max": 2, + "states": { + "0": "LED", + "1": "Switch and LED", + "2": "Button activated" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Button 2 - Binary Switch Support" + }, + "value": 2 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 28, + "propertyName": "Button 2 LED Indicator", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 2 LED Indicator", + "default": 7, + "min": 0, + "max": 7, + "states": { + "0": "Disable", + "1": "Follow switch", + "2": "Follow switch - inverted", + "5": "Follow internal dimmer", + "6": "Follow internal dimmer - inverted", + "7": "On for 5 seconds" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Button 2 LED Indicator" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 29, + "propertyName": "Button 2 LED Indicator Color Commands", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 2 LED Indicator Color Commands", + "default": 0, + "min": 0, + "max": 2, + "states": { + "0": "Direct control", + "1": "Color for off state", + "2": "Color for on state" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Button 2 LED Indicator Color Commands" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 30, + "propertyKey": 4278190080, + "propertyName": "Button 2 LED Indicator (On): Red", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 2 LED Indicator (On): Red", + "default": 0, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 2 LED Indicator (On): Red" + }, + "value": 255 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 30, + "propertyKey": 16711680, + "propertyName": "Button 2 LED Indicator (On): Green", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 2 LED Indicator (On): Green", + "default": 0, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 2 LED Indicator (On): Green" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 30, + "propertyKey": 65280, + "propertyName": "Button 2 LED Indicator (On): Blue", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 2 LED Indicator (On): Blue", + "default": 127, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 2 LED Indicator (On): Blue" + }, + "value": 234 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 30, + "propertyKey": 255, + "propertyName": "LED Time For Button 2 (On): Blinking", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "LED Time For Button 2 (On): Blinking", + "default": 0, + "min": 0, + "max": 255, + "unit": "100 ms", + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "LED Time For Button 2 (On): Blinking" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 31, + "propertyKey": 4278190080, + "propertyName": "Button 2 LED Indicator (Off) Red", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 2 LED Indicator (Off) Red", + "default": 47, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 2 LED Indicator (Off) Red" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 31, + "propertyKey": 16711680, + "propertyName": "Button 2 LED Indicator (Off) Green", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 2 LED Indicator (Off) Green", + "default": 47, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 2 LED Indicator (Off) Green" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 31, + "propertyKey": 65280, + "propertyName": "Button 2 LED Indicator (Off) Blue", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 2 LED Indicator (Off) Blue", + "default": 47, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 2 LED Indicator (Off) Blue" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 31, + "propertyKey": 255, + "propertyName": "LED Time For Button 2 (Off) Blinking", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "LED Time For Button 2 (Off) Blinking", + "default": 0, + "min": 0, + "max": 255, + "unit": "100 ms", + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "LED Time For Button 2 (Off) Blinking" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 32, + "propertyName": "Button 3 Functionality", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 3 Functionality", + "default": 0, + "min": 0, + "max": 4, + "states": { + "0": "Toggle", + "1": "Automatic turn off after time expired", + "2": "Automatic turn on after time expired", + "3": "Always turn off or dim down", + "4": "Always turn on or dim up" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Button 3 Functionality" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 33, + "propertyName": "Button 3 - Timer", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 3 - Timer", + "default": 300, + "min": 0, + "max": 43200, + "unit": "seconds", + "valueSize": 2, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 3 - Timer" + }, + "value": 300 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 34, + "propertyKey": 4278190080, + "propertyName": "Button 3 - Single Press", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 3 - Single Press", + "default": 1, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Button 3 - Single Press" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 34, + "propertyKey": 16711680, + "propertyName": "Button 3 - Single Press (On Value)", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 3 - Single Press (On Value)", + "default": 255, + "min": 0, + "max": 99, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 3 - Single Press (On Value)" + }, + "value": 255 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 34, + "propertyKey": 65280, + "propertyName": "Button 3 - Single Press (Off Value)", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 3 - Single Press (Off Value)", + "default": 0, + "min": 0, + "max": 99, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 3 - Single Press (Off Value)" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 35, + "propertyName": "Button 3 - Binary Switch Support", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 3 - Binary Switch Support", + "default": 0, + "min": 0, + "max": 2, + "states": { + "0": "LED", + "1": "Switch and LED", + "2": "Button activated" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Button 3 - Binary Switch Support" + }, + "value": 2 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 36, + "propertyName": "Button 3 LED Indicator", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 3 LED Indicator", + "default": 7, + "min": 0, + "max": 7, + "states": { + "0": "Disable", + "1": "Follow switch", + "2": "Follow switch - inverted", + "5": "Follow internal dimmer", + "6": "Follow internal dimmer - inverted", + "7": "On for 5 seconds" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Button 3 LED Indicator" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 37, + "propertyName": "Button 3 LED Indicator Color Commands", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 3 LED Indicator Color Commands", + "default": 0, + "min": 0, + "max": 2, + "states": { + "0": "Direct control", + "1": "Color for off state", + "2": "Color for on state" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Button 3 LED Indicator Color Commands" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 38, + "propertyKey": 4278190080, + "propertyName": "Button 3 LED Indicator (On): Red", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 3 LED Indicator (On): Red", + "default": 0, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 3 LED Indicator (On): Red" + }, + "value": 255 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 38, + "propertyKey": 16711680, + "propertyName": "Button 3 LED Indicator (On): Green", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 3 LED Indicator (On): Green", + "default": 0, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 3 LED Indicator (On): Green" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 38, + "propertyKey": 65280, + "propertyName": "Button 3 LED Indicator (On): Blue", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 3 LED Indicator (On): Blue", + "default": 127, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 3 LED Indicator (On): Blue" + }, + "value": 234 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 38, + "propertyKey": 255, + "propertyName": "LED Time For Button 3 (On): Blinking", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "LED Time For Button 3 (On): Blinking", + "default": 0, + "min": 0, + "max": 255, + "unit": "100 ms", + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "LED Time For Button 3 (On): Blinking" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 39, + "propertyKey": 4278190080, + "propertyName": "Button 3 LED Indicator (Off): Red", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 3 LED Indicator (Off): Red", + "default": 47, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 3 LED Indicator (Off): Red" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 39, + "propertyKey": 16711680, + "propertyName": "Button 3 LED Indicator (Off): Green", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 3 LED Indicator (Off): Green", + "default": 47, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 3 LED Indicator (Off): Green" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 39, + "propertyKey": 65280, + "propertyName": "Button 3 LED Indicator (Off): Blue", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 3 LED Indicator (Off): Blue", + "default": 47, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 3 LED Indicator (Off): Blue" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 39, + "propertyKey": 255, + "propertyName": "LED Time For Button 3 (Off): Blinking", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "LED Time For Button 3 (Off): Blinking", + "default": 0, + "min": 0, + "max": 255, + "unit": "100 ms", + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "LED Time For Button 3 (Off): Blinking" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 40, + "propertyName": "Button 4 Functionality", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 4 Functionality", + "default": 0, + "min": 0, + "max": 4, + "states": { + "0": "Toggle", + "1": "Automatic turn off after time expired", + "2": "Automatic turn on after time expired", + "3": "Always turn off or dim down", + "4": "Always turn on or dim up" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Button 4 Functionality" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 41, + "propertyName": "Button 4 - Timer", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 4 - Timer", + "default": 300, + "min": 0, + "max": 43200, + "unit": "seconds", + "valueSize": 2, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 4 - Timer" + }, + "value": 300 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 42, + "propertyKey": 4278190080, + "propertyName": "Button 4 - Single Press", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 4 - Single Press", + "default": 1, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Button 4 - Single Press" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 42, + "propertyKey": 16711680, + "propertyName": "Button 4 - Single Press (On Value)", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 4 - Single Press (On Value)", + "default": 255, + "min": 0, + "max": 99, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 4 - Single Press (On Value)" + }, + "value": 255 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 42, + "propertyKey": 65280, + "propertyName": "Button 4 - Single Press (Off Value)", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 4 - Single Press (Off Value)", + "default": 0, + "min": 0, + "max": 99, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 4 - Single Press (Off Value)" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 43, + "propertyName": "Button 4 - Binary Switch Support", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 4 - Binary Switch Support", + "default": 0, + "min": 0, + "max": 2, + "states": { + "0": "LED", + "1": "Switch and LED", + "2": "Button activated" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Button 4 - Binary Switch Support" + }, + "value": 2 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 44, + "propertyName": "Button 4 LED Indicator", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 4 LED Indicator", + "default": 7, + "min": 0, + "max": 7, + "states": { + "0": "Disable", + "1": "Follow switch", + "2": "Follow switch - inverted", + "5": "Follow internal dimmer", + "6": "Follow internal dimmer - inverted", + "7": "On for 5 seconds" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Button 4 LED Indicator" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 45, + "propertyName": "Button 4 LED Indicator Color Commands", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 4 LED Indicator Color Commands", + "default": 0, + "min": 0, + "max": 2, + "states": { + "0": "Direct control", + "1": "Color for off state", + "2": "Color for on state" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Button 4 LED Indicator Color Commands" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 46, + "propertyKey": 4278190080, + "propertyName": "Button 4 LED Indicator (On): Red", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 4 LED Indicator (On): Red", + "default": 0, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 4 LED Indicator (On): Red" + }, + "value": 255 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 46, + "propertyKey": 16711680, + "propertyName": "Button 4 LED Indicator (On): Green", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 4 LED Indicator (On): Green", + "default": 0, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 4 LED Indicator (On): Green" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 46, + "propertyKey": 65280, + "propertyName": "Button 4 LED Indicator (On): Blue", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 4 LED Indicator (On): Blue", + "default": 127, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 4 LED Indicator (On): Blue" + }, + "value": 234 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 46, + "propertyKey": 255, + "propertyName": "LED Time For Button 4 (On): Blinking", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "LED Time For Button 4 (On): Blinking", + "default": 1, + "min": 0, + "max": 255, + "unit": "100 ms", + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "LED Time For Button 4 (On): Blinking" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 47, + "propertyKey": 4278190080, + "propertyName": "Button 4 LED Indicator (Off): Red", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 4 LED Indicator (Off): Red", + "default": 47, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 4 LED Indicator (Off): Red" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 47, + "propertyKey": 16711680, + "propertyName": "Button 4 LED Indicator (Off): Green", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 4 LED Indicator (Off): Green", + "default": 47, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 4 LED Indicator (Off): Green" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 47, + "propertyKey": 65280, + "propertyName": "Button 4 LED Indicator (Off): Blue", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 4 LED Indicator (Off): Blue", + "default": 47, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 4 LED Indicator (Off): Blue" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 47, + "propertyKey": 255, + "propertyName": "LED Time For Button 4 (Off): Blinking", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "LED Time For Button 4 (Off): Blinking", + "default": 0, + "min": 0, + "max": 255, + "unit": "100 ms", + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "LED Time For Button 4 (Off): Blinking" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 9, + "propertyName": "Dimmer on level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "", + "label": "Dimmer on level", + "default": 0, + "min": 0, + "max": 227, + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": false, + "name": "Dimmer on level", + "info": "" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "manufacturerId", + "propertyName": "manufacturerId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Manufacturer ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 564 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productType", + "propertyName": "productType", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product type", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productId", + "propertyName": "productId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 289 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "libraryType", + "propertyName": "libraryType", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Library type", + "states": { + "0": "Unknown", + "1": "Static Controller", + "2": "Controller", + "3": "Enhanced Slave", + "4": "Slave", + "5": "Installer", + "6": "Routing Slave", + "7": "Bridge Controller", + "8": "Device under Test", + "9": "N/A", + "10": "AV Remote", + "11": "AV Device" + }, + "stateful": true, + "secret": false + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "protocolVersion", + "propertyName": "protocolVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version", + "stateful": true, + "secret": false + }, + "value": "5.3" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "firmwareVersions", + "propertyName": "firmwareVersions", + "ccVersion": 3, + "metadata": { + "type": "string[]", + "readable": true, + "writeable": false, + "label": "Z-Wave chip firmware versions", + "stateful": true, + "secret": false + }, + "value": ["1.8"] + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hardwareVersion", + "propertyName": "hardwareVersion", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Z-Wave chip hardware version", + "stateful": true, + "secret": false + }, + "value": 2 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "sdkVersion", + "propertyName": "sdkVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "SDK version", + "stateful": true, + "secret": false + }, + "value": "6.71.3" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationFrameworkAPIVersion", + "propertyName": "applicationFrameworkAPIVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave application framework API version", + "stateful": true, + "secret": false + }, + "value": "3.1.1" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationFrameworkBuildNumber", + "propertyName": "applicationFrameworkBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave application framework API build number", + "stateful": true, + "secret": false + }, + "value": 52445 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hostInterfaceVersion", + "propertyName": "hostInterfaceVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Serial API version", + "stateful": true, + "secret": false + }, + "value": "unused" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hostInterfaceBuildNumber", + "propertyName": "hostInterfaceBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Serial API build number", + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "zWaveProtocolVersion", + "propertyName": "zWaveProtocolVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version", + "stateful": true, + "secret": false + }, + "value": "5.3.0" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "zWaveProtocolBuildNumber", + "propertyName": "zWaveProtocolBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol build number", + "stateful": true, + "secret": false + }, + "value": 43 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationVersion", + "propertyName": "applicationVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Application version", + "stateful": true, + "secret": false + }, + "value": "1.8.0" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationBuildNumber", + "propertyName": "applicationBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Application build number", + "stateful": true, + "secret": false + }, + "value": 1 + }, + { + "endpoint": 1, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 1, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + } + }, + { + "endpoint": 1, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "currentColor", + "propertyKey": 2, + "propertyName": "currentColor", + "propertyKeyName": "Red", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "description": "The current value of the Red channel.", + "label": "Current value (Red)", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 1, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "currentColor", + "propertyKey": 3, + "propertyName": "currentColor", + "propertyKeyName": "Green", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "description": "The current value of the Green channel.", + "label": "Current value (Green)", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 1, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "currentColor", + "propertyKey": 4, + "propertyName": "currentColor", + "propertyKeyName": "Blue", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "description": "The current value of the Blue channel.", + "label": "Current value (Blue)", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 1, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "currentColor", + "propertyName": "currentColor", + "ccVersion": 1, + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Current color", + "stateful": true, + "secret": false + }, + "value": { + "red": 0, + "green": 0, + "blue": 0 + } + }, + { + "endpoint": 1, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "hexColor", + "propertyName": "hexColor", + "ccVersion": 1, + "metadata": { + "type": "color", + "readable": true, + "writeable": true, + "label": "RGB Color", + "valueChangeOptions": ["transitionDuration"], + "minLength": 6, + "maxLength": 7, + "stateful": true, + "secret": false + }, + "value": "000000" + }, + { + "endpoint": 1, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "targetColor", + "propertyKey": 2, + "propertyName": "targetColor", + "propertyKeyName": "Red", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "The target value of the Red channel.", + "label": "Target value (Red)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 1, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "targetColor", + "propertyKey": 3, + "propertyName": "targetColor", + "propertyKeyName": "Green", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "The target value of the Green channel.", + "label": "Target value (Green)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 1, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "targetColor", + "propertyKey": 4, + "propertyName": "targetColor", + "propertyKeyName": "Blue", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "The target value of the Blue channel.", + "label": "Target value (Blue)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 1, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "targetColor", + "propertyName": "targetColor", + "ccVersion": 1, + "metadata": { + "type": "any", + "readable": true, + "writeable": true, + "label": "Target color", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + } + }, + { + "endpoint": 1, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 2, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 2, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + } + }, + { + "endpoint": 2, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "currentColor", + "propertyKey": 2, + "propertyName": "currentColor", + "propertyKeyName": "Red", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "description": "The current value of the Red channel.", + "label": "Current value (Red)", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 2, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "currentColor", + "propertyKey": 3, + "propertyName": "currentColor", + "propertyKeyName": "Green", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "description": "The current value of the Green channel.", + "label": "Current value (Green)", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 2, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "currentColor", + "propertyKey": 4, + "propertyName": "currentColor", + "propertyKeyName": "Blue", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "description": "The current value of the Blue channel.", + "label": "Current value (Blue)", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 2, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "currentColor", + "propertyName": "currentColor", + "ccVersion": 1, + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Current color", + "stateful": true, + "secret": false + }, + "value": { + "red": 0, + "green": 0, + "blue": 0 + } + }, + { + "endpoint": 2, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "hexColor", + "propertyName": "hexColor", + "ccVersion": 1, + "metadata": { + "type": "color", + "readable": true, + "writeable": true, + "label": "RGB Color", + "valueChangeOptions": ["transitionDuration"], + "minLength": 6, + "maxLength": 7, + "stateful": true, + "secret": false + }, + "value": "000000" + }, + { + "endpoint": 2, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "targetColor", + "propertyKey": 2, + "propertyName": "targetColor", + "propertyKeyName": "Red", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "The target value of the Red channel.", + "label": "Target value (Red)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 2, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "targetColor", + "propertyKey": 3, + "propertyName": "targetColor", + "propertyKeyName": "Green", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "The target value of the Green channel.", + "label": "Target value (Green)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 2, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "targetColor", + "propertyKey": 4, + "propertyName": "targetColor", + "propertyKeyName": "Blue", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "The target value of the Blue channel.", + "label": "Target value (Blue)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 2, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "targetColor", + "propertyName": "targetColor", + "ccVersion": 1, + "metadata": { + "type": "any", + "readable": true, + "writeable": true, + "label": "Target color", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + } + }, + { + "endpoint": 2, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 3, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 3, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + } + }, + { + "endpoint": 3, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "currentColor", + "propertyKey": 2, + "propertyName": "currentColor", + "propertyKeyName": "Red", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "description": "The current value of the Red channel.", + "label": "Current value (Red)", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 3, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "currentColor", + "propertyKey": 3, + "propertyName": "currentColor", + "propertyKeyName": "Green", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "description": "The current value of the Green channel.", + "label": "Current value (Green)", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 3, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "currentColor", + "propertyKey": 4, + "propertyName": "currentColor", + "propertyKeyName": "Blue", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "description": "The current value of the Blue channel.", + "label": "Current value (Blue)", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 3, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "currentColor", + "propertyName": "currentColor", + "ccVersion": 1, + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Current color", + "stateful": true, + "secret": false + }, + "value": { + "red": 0, + "green": 0, + "blue": 0 + } + }, + { + "endpoint": 3, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "hexColor", + "propertyName": "hexColor", + "ccVersion": 1, + "metadata": { + "type": "color", + "readable": true, + "writeable": true, + "label": "RGB Color", + "valueChangeOptions": ["transitionDuration"], + "minLength": 6, + "maxLength": 7, + "stateful": true, + "secret": false + }, + "value": "000000" + }, + { + "endpoint": 3, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "targetColor", + "propertyKey": 2, + "propertyName": "targetColor", + "propertyKeyName": "Red", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "The target value of the Red channel.", + "label": "Target value (Red)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 3, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "targetColor", + "propertyKey": 3, + "propertyName": "targetColor", + "propertyKeyName": "Green", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "The target value of the Green channel.", + "label": "Target value (Green)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 3, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "targetColor", + "propertyKey": 4, + "propertyName": "targetColor", + "propertyKeyName": "Blue", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "The target value of the Blue channel.", + "label": "Target value (Blue)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 3, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "targetColor", + "propertyName": "targetColor", + "ccVersion": 1, + "metadata": { + "type": "any", + "readable": true, + "writeable": true, + "label": "Target color", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + }, + "nodeId": 116, + "value": { + "red": 0, + "green": 0, + "blue": 0 + } + }, + { + "endpoint": 3, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 4, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 4, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + } + }, + { + "endpoint": 4, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "currentColor", + "propertyKey": 2, + "propertyName": "currentColor", + "propertyKeyName": "Red", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "description": "The current value of the Red channel.", + "label": "Current value (Red)", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 4, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "currentColor", + "propertyKey": 3, + "propertyName": "currentColor", + "propertyKeyName": "Green", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "description": "The current value of the Green channel.", + "label": "Current value (Green)", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 4, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "currentColor", + "propertyKey": 4, + "propertyName": "currentColor", + "propertyKeyName": "Blue", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "description": "The current value of the Blue channel.", + "label": "Current value (Blue)", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 4, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "currentColor", + "propertyName": "currentColor", + "ccVersion": 1, + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Current color", + "stateful": true, + "secret": false + }, + "value": { + "red": 0, + "green": 0, + "blue": 0 + } + }, + { + "endpoint": 4, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "hexColor", + "propertyName": "hexColor", + "ccVersion": 1, + "metadata": { + "type": "color", + "readable": true, + "writeable": true, + "label": "RGB Color", + "valueChangeOptions": ["transitionDuration"], + "minLength": 6, + "maxLength": 7, + "stateful": true, + "secret": false + }, + "value": "000000" + }, + { + "endpoint": 4, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "targetColor", + "propertyKey": 2, + "propertyName": "targetColor", + "propertyKeyName": "Red", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "The target value of the Red channel.", + "label": "Target value (Red)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 4, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "targetColor", + "propertyKey": 3, + "propertyName": "targetColor", + "propertyKeyName": "Green", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "The target value of the Green channel.", + "label": "Target value (Green)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 4, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "targetColor", + "propertyKey": 4, + "propertyName": "targetColor", + "propertyKeyName": "Blue", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "The target value of the Blue channel.", + "label": "Target value (Blue)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 4, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "targetColor", + "propertyName": "targetColor", + "ccVersion": 1, + "metadata": { + "type": "any", + "readable": true, + "writeable": true, + "label": "Target color", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + }, + "nodeId": 116, + "value": { + "red": 0, + "green": 0, + "blue": 0 + } + }, + { + "endpoint": 4, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 5, + "commandClass": 32, + "commandClassName": "Basic", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Current value", + "min": 0, + "max": 99, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 5, + "commandClass": 32, + "commandClassName": "Basic", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Target value", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 5, + "commandClass": 32, + "commandClassName": "Basic", + "property": "duration", + "propertyName": "duration", + "ccVersion": 2, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + }, + "value": { + "value": 0, + "unit": "seconds" + } + }, + { + "endpoint": 5, + "commandClass": 32, + "commandClassName": "Basic", + "property": "restorePrevious", + "propertyName": "restorePrevious", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Restore previous value", + "states": { + "true": "Restore" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 5, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 5, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + } + }, + { + "endpoint": 5, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 99, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 5, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 4, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + }, + "value": { + "value": 0, + "unit": "seconds" + } + }, + { + "endpoint": 5, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Current value", + "min": 0, + "max": 99, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 5, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Up", + "propertyName": "Up", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Perform a level change (Up)", + "ccSpecific": { + "switchType": 2 + }, + "valueChangeOptions": ["transitionDuration"], + "states": { + "true": "Start", + "false": "Stop" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 5, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Down", + "propertyName": "Down", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Perform a level change (Down)", + "ccSpecific": { + "switchType": 2 + }, + "valueChangeOptions": ["transitionDuration"], + "states": { + "true": "Start", + "false": "Stop" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 5, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "restorePrevious", + "propertyName": "restorePrevious", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Restore previous value", + "states": { + "true": "Restore" + }, + "stateful": true, + "secret": false + } + } + ], + "isFrequentListening": false, + "maxDataRate": 100000, + "supportedDataRates": [40000, 100000], + "protocolVersion": 3, + "supportsBeaming": true, + "supportsSecurity": false, + "nodeType": 1, + "zwavePlusNodeType": 0, + "zwavePlusRoleType": 5, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 24, + "label": "Wall Controller" + }, + "specific": { + "key": 1, + "label": "Basic Wall Controller" + }, + "mandatorySupportedCCs": [], + "mandatoryControlledCCs": [] + }, + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x0234:0x0003:0x0121:1.8.0", + "statistics": { + "commandsTX": 416, + "commandsRX": 415, + "commandsDroppedRX": 0, + "commandsDroppedTX": 0, + "timeoutResponse": 0, + "rtt": 29.4, + "lastSeen": "2023-08-20T09:41:00.683Z", + "rssi": -71, + "lwr": { + "protocolDataRate": 3, + "repeaters": [], + "rssi": -71, + "repeaterRSSI": [] + } + }, + "highestSecurityClass": -1, + "isControllerNode": false, + "keepAwake": false +} diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index e686def8883cb4..02ed507cabea8d 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -3679,7 +3679,6 @@ async def test_abort_firmware_update( ws_client = await hass_ws_client(hass) device = get_device(hass, multisensor_6) - client.async_send_command.return_value = {} await ws_client.send_json( { ID: 1, @@ -3690,8 +3689,8 @@ async def test_abort_firmware_update( msg = await ws_client.receive_json() assert msg["success"] - assert len(client.async_send_command.call_args_list) == 1 - args = client.async_send_command.call_args[0][0] + assert len(client.async_send_command_no_wait.call_args_list) == 1 + args = client.async_send_command_no_wait.call_args[0][0] assert args["command"] == "node.abort_firmware_update" assert args["nodeId"] == multisensor_6.node_id diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index 73dd82d5f4b176..a051f398d8c9e2 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -2,6 +2,7 @@ import asyncio from collections.abc import Generator from copy import copy +from ipaddress import ip_address from unittest.mock import DEFAULT, MagicMock, call, patch import aiohttp @@ -2672,8 +2673,8 @@ async def test_zeroconf(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=ZeroconfServiceInfo( - host="localhost", - addresses=["127.0.0.1"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="mock_name", port=3000, @@ -2697,7 +2698,7 @@ async def test_zeroconf(hass: HomeAssistant) -> None: assert result["type"] == "create_entry" assert result["title"] == TITLE assert result["data"] == { - "url": "ws://localhost:3000", + "url": "ws://127.0.0.1:3000", "usb_path": None, "s0_legacy_key": None, "s2_access_control_key": None, diff --git a/tests/components/zwave_js/test_light.py b/tests/components/zwave_js/test_light.py index 3a862ee3a0c427..4b0345b00ead66 100644 --- a/tests/components/zwave_js/test_light.py +++ b/tests/components/zwave_js/test_light.py @@ -35,6 +35,7 @@ ) HSM200_V1_ENTITY = "light.hsm200" +ZDB5100_ENTITY = "light.matrix_office" async def test_light( @@ -681,3 +682,180 @@ async def test_black_is_off( "property": "targetColor", } assert args["value"] == {"red": 255, "green": 76, "blue": 255} + + +async def test_black_is_off_zdb5100( + hass: HomeAssistant, client, logic_group_zdb5100, integration +) -> None: + """Test the black is off light entity.""" + node = logic_group_zdb5100 + state = hass.states.get(ZDB5100_ENTITY) + assert state.state == STATE_OFF + + # Attempt to turn on the light and ensure it defaults to white + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ZDB5100_ENTITY}, + blocking=True, + ) + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args_list[0][0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == node.node_id + assert args["valueId"] == { + "commandClass": 51, + "endpoint": 1, + "property": "targetColor", + } + assert args["value"] == {"red": 255, "green": 255, "blue": 255} + + client.async_send_command.reset_mock() + + # Force the light to turn off + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node.node_id, + "args": { + "commandClassName": "Color Switch", + "commandClass": 51, + "endpoint": 1, + "property": "currentColor", + "newValue": { + "red": 0, + "green": 0, + "blue": 0, + }, + "prevValue": { + "red": 0, + "green": 255, + "blue": 0, + }, + "propertyName": "currentColor", + }, + }, + ) + node.receive_event(event) + await hass.async_block_till_done() + state = hass.states.get(ZDB5100_ENTITY) + assert state.state == STATE_OFF + + # Force the light to turn on + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node.node_id, + "args": { + "commandClassName": "Color Switch", + "commandClass": 51, + "endpoint": 1, + "property": "currentColor", + "newValue": { + "red": 0, + "green": 255, + "blue": 0, + }, + "prevValue": { + "red": 0, + "green": 0, + "blue": 0, + }, + "propertyName": "currentColor", + }, + }, + ) + node.receive_event(event) + await hass.async_block_till_done() + state = hass.states.get(ZDB5100_ENTITY) + assert state.state == STATE_ON + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ZDB5100_ENTITY}, + blocking=True, + ) + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args_list[0][0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == node.node_id + assert args["valueId"] == { + "commandClass": 51, + "endpoint": 1, + "property": "targetColor", + } + assert args["value"] == {"red": 0, "green": 0, "blue": 0} + + client.async_send_command.reset_mock() + + # Assert that the last color is restored + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ZDB5100_ENTITY}, + blocking=True, + ) + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args_list[0][0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == node.node_id + assert args["valueId"] == { + "commandClass": 51, + "endpoint": 1, + "property": "targetColor", + } + assert args["value"] == {"red": 0, "green": 255, "blue": 0} + + client.async_send_command.reset_mock() + + # Force the light to turn on + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node.node_id, + "args": { + "commandClassName": "Color Switch", + "commandClass": 51, + "endpoint": 1, + "property": "currentColor", + "newValue": None, + "prevValue": { + "red": 0, + "green": 255, + "blue": 0, + }, + "propertyName": "currentColor", + }, + }, + ) + node.receive_event(event) + await hass.async_block_till_done() + state = hass.states.get(ZDB5100_ENTITY) + assert state.state == STATE_UNKNOWN + + client.async_send_command.reset_mock() + + # Assert that call fails if attribute is added to service call + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ZDB5100_ENTITY, ATTR_RGBW_COLOR: (255, 76, 255, 0)}, + blocking=True, + ) + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args_list[0][0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == node.node_id + assert args["valueId"] == { + "commandClass": 51, + "endpoint": 1, + "property": "targetColor", + } + assert args["value"] == {"red": 255, "green": 76, "blue": 255} diff --git a/tests/components/zwave_js/test_repairs.py b/tests/components/zwave_js/test_repairs.py index 07371a299efa41..d18bcfa09aa534 100644 --- a/tests/components/zwave_js/test_repairs.py +++ b/tests/components/zwave_js/test_repairs.py @@ -22,16 +22,10 @@ from tests.typing import ClientSessionGenerator, WebSocketGenerator -async def test_device_config_file_changed( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - hass_ws_client: WebSocketGenerator, - client, - multisensor_6_state, - integration, -) -> None: - """Test the device_config_file_changed issue.""" - dev_reg = dr.async_get(hass) +async def _trigger_repair_issue( + hass: HomeAssistant, client, multisensor_6_state +) -> Node: + """Trigger repair issue.""" # Create a node node_state = deepcopy(multisensor_6_state) node = Node(client, node_state) @@ -53,6 +47,23 @@ async def test_device_config_file_changed( client.async_send_command_no_wait.reset_mock() + return node + + +async def test_device_config_file_changed( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, + client, + multisensor_6_state, + integration, +) -> None: + """Test the device_config_file_changed issue.""" + dev_reg = dr.async_get(hass) + node = await _trigger_repair_issue(hass, client, multisensor_6_state) + + client.async_send_command_no_wait.reset_mock() + device = dev_reg.async_get_device(identifiers={get_device_id(client.driver, node)}) assert device issue_id = f"device_config_file_changed.{device.id}" @@ -157,3 +168,46 @@ async def test_invalid_issue( msg = await ws_client.receive_json() assert msg["success"] assert len(msg["result"]["issues"]) == 0 + + +async def test_abort_confirm( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, + client, + multisensor_6_state, + integration, +) -> None: + """Test aborting device_config_file_changed issue in confirm step.""" + dev_reg = dr.async_get(hass) + node = await _trigger_repair_issue(hass, client, multisensor_6_state) + + device = dev_reg.async_get_device(identifiers={get_device_id(client.driver, node)}) + assert device + issue_id = f"device_config_file_changed.{device.id}" + + await async_process_repairs_platforms(hass) + await hass_ws_client(hass) + http_client = await hass_client() + + url = RepairsFlowIndexView.url + resp = await http_client.post(url, json={"handler": DOMAIN, "issue_id": issue_id}) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data["step_id"] == "confirm" + + # Unload config entry so we can't connect to the node + await hass.config_entries.async_unload(integration.entry_id) + + # Apply fix + url = RepairsFlowResourceView.url.format(flow_id=flow_id) + resp = await http_client.post(url) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + assert data["type"] == "abort" + assert data["reason"] == "cannot_connect" + assert data["description_placeholders"] == {"device_name": device.name} diff --git a/tests/components/zwave_me/test_config_flow.py b/tests/components/zwave_me/test_config_flow.py index 7d1919a8698894..145cecd58c8491 100644 --- a/tests/components/zwave_me/test_config_flow.py +++ b/tests/components/zwave_me/test_config_flow.py @@ -1,4 +1,5 @@ """Test the zwave_me config flow.""" +from ipaddress import ip_address from unittest.mock import patch from homeassistant import config_entries @@ -10,10 +11,10 @@ from tests.common import MockConfigEntry MOCK_ZEROCONF_DATA = zeroconf.ZeroconfServiceInfo( - host="ws://192.168.1.14", + ip_address=ip_address("192.168.1.14"), + ip_addresses=[ip_address("192.168.1.14")], hostname="mock_hostname", name="mock_name", - addresses=["192.168.1.14"], port=1234, properties={ "deviceid": "aa:bb:cc:dd:ee:ff", diff --git a/tests/conftest.py b/tests/conftest.py index f90984e1c7bc8f..f743a2fe96a4a7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,12 +3,13 @@ import asyncio from collections.abc import AsyncGenerator, Callable, Coroutine, Generator -from contextlib import asynccontextmanager +from contextlib import asynccontextmanager, contextmanager import functools import gc import itertools import logging import os +import reprlib import sqlite3 import ssl import threading @@ -302,6 +303,27 @@ def skip_stop_scripts( yield +@contextmanager +def long_repr_strings() -> Generator[None, None, None]: + """Increase reprlib maxstring and maxother to 300.""" + arepr = reprlib.aRepr + original_maxstring = arepr.maxstring + original_maxother = arepr.maxother + arepr.maxstring = 300 + arepr.maxother = 300 + try: + yield + finally: + arepr.maxstring = original_maxstring + arepr.maxother = original_maxother + + +@pytest.fixture(autouse=True) +def enable_event_loop_debug(event_loop: asyncio.AbstractEventLoop) -> None: + """Enable event loop debug mode.""" + event_loop.set_debug(True) + + @pytest.fixture(autouse=True) def verify_cleanup( event_loop: asyncio.AbstractEventLoop, @@ -335,13 +357,16 @@ def verify_cleanup( for handle in event_loop._scheduled: # type: ignore[attr-defined] if not handle.cancelled(): - if expected_lingering_timers: - _LOGGER.warning("Lingering timer after test %r", handle) - elif handle._args and isinstance(job := handle._args[0], HassJob): - pytest.fail(f"Lingering timer after job {repr(job)}") - else: - pytest.fail(f"Lingering timer after test {repr(handle)}") - handle.cancel() + with long_repr_strings(): + if expected_lingering_timers: + _LOGGER.warning("Lingering timer after test %r", handle) + elif handle._args and isinstance(job := handle._args[-1], HassJob): + if job.cancel_on_shutdown: + continue + pytest.fail(f"Lingering timer after job {repr(job)}") + else: + pytest.fail(f"Lingering timer after test {repr(handle)}") + handle.cancel() # Verify no threads where left behind. threads = frozenset(threading.enumerate()) - threads_before @@ -1276,6 +1301,11 @@ def hass_recorder( hass = get_test_home_assistant() nightly = recorder.Recorder.async_nightly_tasks if enable_nightly_purge else None stats = recorder.Recorder.async_periodic_statistics if enable_statistics else None + compile_missing = ( + recorder.Recorder._schedule_compile_missing_statistics + if enable_statistics + else None + ) schema_validate = ( migration._find_schema_errors if enable_schema_validation @@ -1327,6 +1357,10 @@ def hass_recorder( "homeassistant.components.recorder.Recorder._migrate_entity_ids", side_effect=migrate_entity_ids, autospec=True, + ), patch( + "homeassistant.components.recorder.Recorder._schedule_compile_missing_statistics", + side_effect=compile_missing, + autospec=True, ): def setup_recorder(config: dict[str, Any] | None = None) -> HomeAssistant: @@ -1399,6 +1433,11 @@ async def async_setup_recorder_instance( if enable_schema_validation else itertools.repeat(set()) ) + compile_missing = ( + recorder.Recorder._schedule_compile_missing_statistics + if enable_statistics + else None + ) migrate_states_context_ids = ( recorder.Recorder._migrate_states_context_ids if enable_migrate_context_ids @@ -1445,6 +1484,10 @@ async def async_setup_recorder_instance( "homeassistant.components.recorder.Recorder._migrate_entity_ids", side_effect=migrate_entity_ids, autospec=True, + ), patch( + "homeassistant.components.recorder.Recorder._schedule_compile_missing_statistics", + side_effect=compile_missing, + autospec=True, ): async def async_setup_recorder( diff --git a/tests/helpers/test_dispatcher.py b/tests/helpers/test_dispatcher.py index e30aaa6e0d9e1c..a251b20b0f41ad 100644 --- a/tests/helpers/test_dispatcher.py +++ b/tests/helpers/test_dispatcher.py @@ -151,3 +151,25 @@ def bad_handler(*args): f"Exception in functools.partial({bad_handler}) when dispatching 'test': ('bad',)" in caplog.text ) + + +async def test_dispatcher_add_dispatcher(hass: HomeAssistant) -> None: + """Test adding a dispatcher from a dispatcher.""" + calls = [] + + @callback + def _new_dispatcher(data): + calls.append(data) + + @callback + def _add_new_dispatcher(data): + calls.append(data) + async_dispatcher_connect(hass, "test", _new_dispatcher) + + async_dispatcher_connect(hass, "test", _add_new_dispatcher) + + async_dispatcher_send(hass, "test", 3) + async_dispatcher_send(hass, "test", 4) + async_dispatcher_send(hass, "test", 5) + + assert calls == [3, 4, 4, 5, 5] diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index 200b0230adb2d8..61ee38a66a7a96 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -3,6 +3,7 @@ from collections.abc import Iterable import dataclasses from datetime import timedelta +import logging import threading from typing import Any from unittest.mock import MagicMock, PropertyMock, patch @@ -26,8 +27,10 @@ MockConfigEntry, MockEntity, MockEntityPlatform, + MockModule, MockPlatform, get_test_home_assistant, + mock_integration, mock_registry, ) @@ -775,7 +778,7 @@ class CustomComponentEntity(entity.Entity): assert ( "Updating state for comp_test.test_entity " "(.CustomComponentEntity'>) " - "took 10.000 seconds. Please report it to the custom integration author." + "took 10.000 seconds. Please report it to the custom integration author" ) in caplog.text @@ -794,13 +797,11 @@ async def test_setup_source(hass: HomeAssistant) -> None: "test_domain.platform_config_source": { "custom_component": False, "domain": "test_platform", - "source": entity.SOURCE_PLATFORM_CONFIG, }, "test_domain.config_entry_source": { "config_entry": platform.config_entry.entry_id, "custom_component": False, "domain": "test_platform", - "source": entity.SOURCE_CONFIG_ENTRY, }, } @@ -1477,3 +1478,84 @@ async def test_warn_no_platform( caplog.clear() ent.async_write_ha_state() assert error_message not in caplog.text + + +async def test_invalid_state( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test the entity helper catches InvalidState and sets state to unknown.""" + ent = entity.Entity() + ent.entity_id = "test.test" + ent.hass = hass + + ent._attr_state = "x" * 255 + ent.async_write_ha_state() + assert hass.states.get("test.test").state == "x" * 255 + + caplog.clear() + ent._attr_state = "x" * 256 + ent.async_write_ha_state() + assert hass.states.get("test.test").state == STATE_UNKNOWN + assert ( + "homeassistant.helpers.entity", + logging.ERROR, + f"Failed to set state, fall back to {STATE_UNKNOWN}", + ) in caplog.record_tuples + + ent._attr_state = "x" * 255 + ent.async_write_ha_state() + assert hass.states.get("test.test").state == "x" * 255 + + +async def test_suggest_report_issue_built_in( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test _suggest_report_issue for an entity from a built-in integration.""" + mock_entity = entity.Entity() + mock_entity.entity_id = "comp_test.test_entity" + + suggestion = mock_entity._suggest_report_issue() + assert suggestion == ( + "create a bug report at https://github.com/home-assistant/core/issues" + "?q=is%3Aopen+is%3Aissue" + ) + + mock_integration(hass, MockModule(domain="test"), built_in=True) + platform = MockEntityPlatform(hass, domain="comp_test", platform_name="test") + await platform.async_add_entities([mock_entity]) + + suggestion = mock_entity._suggest_report_issue() + assert suggestion == ( + "create a bug report at https://github.com/home-assistant/core/issues" + "?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+test%22" + ) + + +async def test_suggest_report_issue_custom_component( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test _suggest_report_issue for an entity from a custom component.""" + + class CustomComponentEntity(entity.Entity): + """Custom component entity.""" + + __module__ = "custom_components.bla.sensor" + + mock_entity = CustomComponentEntity() + mock_entity.entity_id = "comp_test.test_entity" + + suggestion = mock_entity._suggest_report_issue() + assert suggestion == "report it to the custom integration author" + + mock_integration( + hass, + MockModule( + domain="test", partial_manifest={"issue_tracker": "httpts://some_url"} + ), + built_in=False, + ) + platform = MockEntityPlatform(hass, domain="comp_test", platform_name="test") + await platform.async_add_entities([mock_entity]) + + suggestion = mock_entity._suggest_report_issue() + assert suggestion == "create a bug report at httpts://some_url" diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index dc06b9d94c84e1..00ad580693e6eb 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -3239,27 +3239,6 @@ def refresh_listener( ] -async def test_async_track_template_result_raise_on_template_error( - hass: HomeAssistant, -) -> None: - """Test that we raise as soon as we encounter a failed template.""" - - with pytest.raises(TemplateError): - async_track_template_result( - hass, - [ - TrackTemplate( - Template( - "{{ states.switch | function_that_does_not_exist | list }}" - ), - None, - ), - ], - ha.callback(lambda event, updates: None), - raise_on_template_error=True, - ) - - async def test_track_template_with_time(hass: HomeAssistant) -> None: """Test tracking template with time.""" diff --git a/tests/helpers/test_integration_platform.py b/tests/helpers/test_integration_platform.py index 2dfc0742e267cb..ed6edcc3690f09 100644 --- a/tests/helpers/test_integration_platform.py +++ b/tests/helpers/test_integration_platform.py @@ -5,7 +5,6 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.integration_platform import ( - async_process_integration_platform_for_component, async_process_integration_platforms, ) from homeassistant.setup import ATTR_COMPONENT, EVENT_COMPONENT_LOADED @@ -43,17 +42,6 @@ async def _process_platform(hass, domain, platform): assert processed[1][0] == "event" assert processed[1][1] == event_platform - # Verify we only process the platform once if we call it manually - await async_process_integration_platform_for_component(hass, "event") - assert len(processed) == 2 - - -async def test_process_integration_platforms_none_loaded(hass: HomeAssistant) -> None: - """Test processing integrations with none loaded.""" - # Verify we can call async_process_integration_platform_for_component - # when there are none loaded and it does not throw - await async_process_integration_platform_for_component(hass, "any") - async def test_broken_integration( hass: HomeAssistant, caplog: pytest.LogCaptureFixture diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index 803a57e12edac9..03a8b5e11b2d8c 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -1,5 +1,4 @@ """Test service helpers.""" -from collections import OrderedDict from collections.abc import Iterable from copy import deepcopy from typing import Any @@ -54,7 +53,7 @@ def mock_handle_entity_call(): @pytest.fixture -def mock_entities(hass): +def mock_entities(hass: HomeAssistant) -> dict[str, MockEntity]: """Return mock entities in an ordered dict.""" kitchen = MockEntity( entity_id="light.kitchen", @@ -80,11 +79,13 @@ def mock_entities(hass): should_poll=False, supported_features=(SUPPORT_B | SUPPORT_C), ) - entities = OrderedDict() + entities = {} entities[kitchen.entity_id] = kitchen entities[living_room.entity_id] = living_room entities[bedroom.entity_id] = bedroom entities[bathroom.entity_id] = bathroom + for entity in entities.values(): + entity.hass = hass return entities diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index d14496d321e337..58e0c730165b50 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -4466,15 +4466,25 @@ async def test_parse_result(hass: HomeAssistant) -> None: assert template.Template(tpl, hass).async_render() == result -async def test_undefined_variable( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture +@pytest.mark.parametrize( + "template_string", + [ + "{{ no_such_variable }}", + "{{ no_such_variable and True }}", + "{{ no_such_variable | join(', ') }}", + ], +) +async def test_undefined_symbol_warnings( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + template_string: str, ) -> None: """Test a warning is logged on undefined variables.""" - tpl = template.Template("{{ no_such_variable }}", hass) + tpl = template.Template(template_string, hass) assert tpl.async_render() == "" assert ( "Template variable warning: 'no_such_variable' is undefined when rendering " - "'{{ no_such_variable }}'" in caplog.text + f"'{template_string}'" in caplog.text ) diff --git a/tests/pylint/conftest.py b/tests/pylint/conftest.py index e8748434350a9e..03f637a646fd46 100644 --- a/tests/pylint/conftest.py +++ b/tests/pylint/conftest.py @@ -1,6 +1,7 @@ """Configuration for pylint tests.""" -from importlib.machinery import SourceFileLoader +from importlib.util import module_from_spec, spec_from_file_location from pathlib import Path +import sys from types import ModuleType from pylint.checkers import BaseChecker @@ -10,14 +11,27 @@ BASE_PATH = Path(__file__).parents[2] +def _load_plugin_from_file(module_name: str, file: str) -> ModuleType: + """Load plugin from file path.""" + spec = spec_from_file_location( + module_name, + str(BASE_PATH.joinpath(file)), + ) + assert spec and spec.loader + + module = module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + return module + + @pytest.fixture(name="hass_enforce_type_hints", scope="session") def hass_enforce_type_hints_fixture() -> ModuleType: """Fixture to provide a requests mocker.""" - loader = SourceFileLoader( + return _load_plugin_from_file( "hass_enforce_type_hints", - str(BASE_PATH.joinpath("pylint/plugins/hass_enforce_type_hints.py")), + "pylint/plugins/hass_enforce_type_hints.py", ) - return loader.load_module(None) @pytest.fixture(name="linter") @@ -37,11 +51,10 @@ def type_hint_checker_fixture(hass_enforce_type_hints, linter) -> BaseChecker: @pytest.fixture(name="hass_imports", scope="session") def hass_imports_fixture() -> ModuleType: """Fixture to provide a requests mocker.""" - loader = SourceFileLoader( + return _load_plugin_from_file( "hass_imports", - str(BASE_PATH.joinpath("pylint/plugins/hass_imports.py")), + "pylint/plugins/hass_imports.py", ) - return loader.load_module(None) @pytest.fixture(name="imports_checker") @@ -50,3 +63,20 @@ def imports_checker_fixture(hass_imports, linter) -> BaseChecker: type_hint_checker = hass_imports.HassImportsFormatChecker(linter) type_hint_checker.module = "homeassistant.components.pylint_test" return type_hint_checker + + +@pytest.fixture(name="hass_enforce_super_call", scope="session") +def hass_enforce_super_call_fixture() -> ModuleType: + """Fixture to provide a requests mocker.""" + return _load_plugin_from_file( + "hass_enforce_super_call", + "pylint/plugins/hass_enforce_super_call.py", + ) + + +@pytest.fixture(name="super_call_checker") +def super_call_checker_fixture(hass_enforce_super_call, linter) -> BaseChecker: + """Fixture to provide a requests mocker.""" + super_call_checker = hass_enforce_super_call.HassEnforceSuperCallChecker(linter) + super_call_checker.module = "homeassistant.components.pylint_test" + return super_call_checker diff --git a/tests/pylint/test_enforce_super_call.py b/tests/pylint/test_enforce_super_call.py new file mode 100644 index 00000000000000..5e2861b1c74c4e --- /dev/null +++ b/tests/pylint/test_enforce_super_call.py @@ -0,0 +1,221 @@ +"""Tests for pylint hass_enforce_super_call plugin.""" +from __future__ import annotations + +from types import ModuleType +from unittest.mock import patch + +import astroid +from pylint.checkers import BaseChecker +from pylint.interfaces import INFERENCE +from pylint.testutils import MessageTest +from pylint.testutils.unittest_linter import UnittestLinter +from pylint.utils.ast_walker import ASTWalker +import pytest + +from . import assert_adds_messages, assert_no_messages + + +@pytest.mark.parametrize( + "code", + [ + pytest.param( + """ + class Entity: + async def async_added_to_hass(self) -> None: + pass + """, + id="no_parent", + ), + pytest.param( + """ + class Entity: + async def async_added_to_hass(self) -> None: + \"\"\"Some docstring.\"\"\" + + class Child(Entity): + async def async_added_to_hass(self) -> None: + x = 2 + """, + id="empty_parent_implementation", + ), + pytest.param( + """ + class Entity: + async def async_added_to_hass(self) -> None: + \"\"\"Some docstring.\"\"\" + pass + + class Child(Entity): + async def async_added_to_hass(self) -> None: + x = 2 + """, + id="empty_parent_implementation2", + ), + pytest.param( + """ + class Entity: + async def async_added_to_hass(self) -> None: + x = 2 + + class Child(Entity): + async def async_added_to_hass(self) -> None: + await super().async_added_to_hass() + """, + id="correct_super_call", + ), + pytest.param( + """ + class Entity: + async def async_added_to_hass(self) -> None: + x = 2 + + class Child(Entity): + async def async_added_to_hass(self) -> None: + return await super().async_added_to_hass() + """, + id="super_call_in_return", + ), + pytest.param( + """ + class Entity: + def added_to_hass(self) -> None: + x = 2 + + class Child(Entity): + def added_to_hass(self) -> None: + super().added_to_hass() + """, + id="super_call_not_async", + ), + pytest.param( + """ + class Entity: + async def async_added_to_hass(self) -> None: + \"\"\"\"\"\" + + class Coordinator: + async def async_added_to_hass(self) -> None: + x = 2 + + class Child(Entity, Coordinator): + async def async_added_to_hass(self) -> None: + await super().async_added_to_hass() + """, + id="multiple_inheritance", + ), + pytest.param( + """ + async def async_added_to_hass() -> None: + x = 2 + """, + id="not_a_method", + ), + ], +) +def test_enforce_super_call( + linter: UnittestLinter, + hass_enforce_super_call: ModuleType, + super_call_checker: BaseChecker, + code: str, +) -> None: + """Good test cases.""" + root_node = astroid.parse(code, "homeassistant.components.pylint_test") + walker = ASTWalker(linter) + walker.add_checker(super_call_checker) + + with patch.object( + hass_enforce_super_call, "METHODS", new={"added_to_hass", "async_added_to_hass"} + ), assert_no_messages(linter): + walker.walk(root_node) + + +@pytest.mark.parametrize( + ("code", "node_idx"), + [ + pytest.param( + """ + class Entity: + def added_to_hass(self) -> None: + x = 2 + + class Child(Entity): + def added_to_hass(self) -> None: + x = 3 + """, + 1, + id="no_super_call", + ), + pytest.param( + """ + class Entity: + async def async_added_to_hass(self) -> None: + x = 2 + + class Child(Entity): + async def async_added_to_hass(self) -> None: + x = 3 + """, + 1, + id="no_super_call_async", + ), + pytest.param( + """ + class Entity: + async def async_added_to_hass(self) -> None: + x = 2 + + class Child(Entity): + async def async_added_to_hass(self) -> None: + await Entity.async_added_to_hass() + """, + 1, + id="explicit_call_to_base_implementation", + ), + pytest.param( + """ + class Entity: + async def async_added_to_hass(self) -> None: + \"\"\"\"\"\" + + class Coordinator: + async def async_added_to_hass(self) -> None: + x = 2 + + class Child(Entity, Coordinator): + async def async_added_to_hass(self) -> None: + x = 3 + """, + 2, + id="multiple_inheritance", + ), + ], +) +def test_enforce_super_call_bad( + linter: UnittestLinter, + hass_enforce_super_call: ModuleType, + super_call_checker: BaseChecker, + code: str, + node_idx: int, +) -> None: + """Bad test cases.""" + root_node = astroid.parse(code, "homeassistant.components.pylint_test") + walker = ASTWalker(linter) + walker.add_checker(super_call_checker) + node = root_node.body[node_idx].body[0] + + with patch.object( + hass_enforce_super_call, "METHODS", new={"added_to_hass", "async_added_to_hass"} + ), assert_adds_messages( + linter, + MessageTest( + msg_id="hass-missing-super-call", + node=node, + line=node.lineno, + args=(node.name,), + col_offset=node.col_offset, + end_line=node.position.end_lineno, + end_col_offset=node.position.end_col_offset, + confidence=INFERENCE, + ), + ): + walker.walk(root_node) diff --git a/tests/syrupy.py b/tests/syrupy.py index 9433eb1649c47e..9209654a607988 100644 --- a/tests/syrupy.py +++ b/tests/syrupy.py @@ -85,6 +85,7 @@ def _serialize( *, depth: int = 0, exclude: PropertyFilter | None = None, + include: PropertyFilter | None = None, matcher: PropertyMatcher | None = None, path: PropertyPath = (), visited: set[Any] | None = None, @@ -125,6 +126,7 @@ def _serialize( serializable_data, depth=depth, exclude=exclude, + include=include, matcher=matcher, path=path, visited=visited, @@ -156,7 +158,6 @@ def _serializable_device_registry_entry( ) if serialized["via_device_id"] is not None: serialized["via_device_id"] = ANY - serialized.pop("_json_repr") return serialized @classmethod @@ -164,7 +165,7 @@ def _serializable_entity_registry_entry( cls, data: er.RegistryEntry ) -> SerializableData: """Prepare a Home Assistant entity registry entry for serialization.""" - serialized = EntityRegistryEntrySnapshot( + return EntityRegistryEntrySnapshot( attrs.asdict(data) | { "config_entry_id": ANY, @@ -173,9 +174,6 @@ def _serializable_entity_registry_entry( "options": {k: dict(v) for k, v in data.options.items()}, } ) - serialized.pop("_partial_repr") - serialized.pop("_display_repr") - return serialized @classmethod def _serializable_flow_result(cls, data: FlowResult) -> SerializableData: diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 760c7138c88965..52caa1ae2756af 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -960,7 +960,7 @@ async def test_setup_raise_not_ready( mock_setup_entry.side_effect = None mock_setup_entry.return_value = True - await p_setup(None) + await hass.async_run_hass_job(p_setup, None) assert entry.state is config_entries.ConfigEntryState.LOADED assert entry.reason is None diff --git a/tests/test_core.py b/tests/test_core.py index 4f7916e757b4d5..7cafadb638ce9e 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -15,6 +15,7 @@ from unittest.mock import MagicMock, Mock, PropertyMock, patch import pytest +from pytest_unordered import unordered import voluptuous as vol from homeassistant.const import ( @@ -670,11 +671,11 @@ def test_state_as_dict_json() -> None: '"last_changed":"1984-12-08T12:00:00","last_updated":"1984-12-08T12:00:00",' '"context":{"id":"01H0D6K3RFJAYAV2093ZW30PCW","parent_id":null,"user_id":null}}' ) - as_dict_json_1 = state.as_dict_json() + as_dict_json_1 = state.as_dict_json assert as_dict_json_1 == expected # 2nd time to verify cache - assert state.as_dict_json() == expected - assert state.as_dict_json() is as_dict_json_1 + assert state.as_dict_json == expected + assert state.as_dict_json is as_dict_json_1 def test_state_as_compressed_state() -> None: @@ -693,12 +694,12 @@ def test_state_as_compressed_state() -> None: "lc": last_time.timestamp(), "s": "on", } - as_compressed_state = state.as_compressed_state() + as_compressed_state = state.as_compressed_state # We are not too concerned about these being ReadOnlyDict # since we don't expect them to be called by external callers assert as_compressed_state == expected # 2nd time to verify cache - assert state.as_compressed_state() == expected + assert state.as_compressed_state == expected def test_state_as_compressed_state_unique_last_updated() -> None: @@ -719,12 +720,12 @@ def test_state_as_compressed_state_unique_last_updated() -> None: "lu": last_updated.timestamp(), "s": "on", } - as_compressed_state = state.as_compressed_state() + as_compressed_state = state.as_compressed_state # We are not too concerned about these being ReadOnlyDict # since we don't expect them to be called by external callers assert as_compressed_state == expected # 2nd time to verify cache - assert state.as_compressed_state() == expected + assert state.as_compressed_state == expected def test_state_as_compressed_state_json() -> None: @@ -739,13 +740,13 @@ def test_state_as_compressed_state_json() -> None: context=ha.Context(id="01H0D6H5K3SZJ3XGDHED1TJ79N"), ) expected = '"happy.happy":{"s":"on","a":{"pig":"dog"},"c":"01H0D6H5K3SZJ3XGDHED1TJ79N","lc":471355200.0}' - as_compressed_state = state.as_compressed_state_json() + as_compressed_state = state.as_compressed_state_json # We are not too concerned about these being ReadOnlyDict # since we don't expect them to be called by external callers assert as_compressed_state == expected # 2nd time to verify cache - assert state.as_compressed_state_json() == expected - assert state.as_compressed_state_json() is as_compressed_state + assert state.as_compressed_state_json == expected + assert state.as_compressed_state_json is as_compressed_state async def test_eventbus_add_remove_listener(hass: HomeAssistant) -> None: @@ -1031,17 +1032,18 @@ async def test_statemachine_is_state(hass: HomeAssistant) -> None: async def test_statemachine_entity_ids(hass: HomeAssistant) -> None: - """Test get_entity_ids method.""" + """Test async_entity_ids method.""" + assert hass.states.async_entity_ids() == [] + assert hass.states.async_entity_ids("light") == [] + assert hass.states.async_entity_ids(("light", "switch", "other")) == [] + hass.states.async_set("light.bowl", "on", {}) hass.states.async_set("SWITCH.AC", "off", {}) - ent_ids = hass.states.async_entity_ids() - assert len(ent_ids) == 2 - assert "light.bowl" in ent_ids - assert "switch.ac" in ent_ids - - ent_ids = hass.states.async_entity_ids("light") - assert len(ent_ids) == 1 - assert "light.bowl" in ent_ids + assert hass.states.async_entity_ids() == unordered(["light.bowl", "switch.ac"]) + assert hass.states.async_entity_ids("light") == ["light.bowl"] + assert hass.states.async_entity_ids(("light", "switch", "other")) == unordered( + ["light.bowl", "switch.ac"] + ) states = sorted(state.entity_id for state in hass.states.async_all()) assert states == ["light.bowl", "switch.ac"] @@ -1902,6 +1904,9 @@ async def _task_chain_2(): async def test_async_all(hass: HomeAssistant) -> None: """Test async_all.""" + assert hass.states.async_all() == [] + assert hass.states.async_all("light") == [] + assert hass.states.async_all(["light", "switch"]) == [] hass.states.async_set("switch.link", "on") hass.states.async_set("light.bowl", "on") @@ -1926,6 +1931,10 @@ async def test_async_all(hass: HomeAssistant) -> None: async def test_async_entity_ids_count(hass: HomeAssistant) -> None: """Test async_entity_ids_count.""" + assert hass.states.async_entity_ids_count() == 0 + assert hass.states.async_entity_ids_count("light") == 0 + assert hass.states.async_entity_ids_count({"light", "vacuum"}) == 0 + hass.states.async_set("switch.link", "on") hass.states.async_set("light.bowl", "on") hass.states.async_set("light.frog", "on") @@ -1938,6 +1947,7 @@ async def test_async_entity_ids_count(hass: HomeAssistant) -> None: assert hass.states.async_entity_ids_count() == 5 assert hass.states.async_entity_ids_count("light") == 3 + assert hass.states.async_entity_ids_count({"light", "vacuum"}) == 4 async def test_hassjob_forbid_coroutine() -> None: @@ -2464,3 +2474,10 @@ def run_job(job: HassJob) -> None: # Cleanup timer2.cancel() + + +async def test_validate_state(hass: HomeAssistant) -> None: + """Test validate_state.""" + assert ha.validate_state("test") == "test" + with pytest.raises(InvalidStateError): + ha.validate_state("t" * 256) diff --git a/tests/test_loader.py b/tests/test_loader.py index 6e62be08f66a6e..b62e25b79e3a2c 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -150,10 +150,17 @@ async def test_custom_integration_version_not_valid( async def test_get_integration(hass: HomeAssistant) -> None: """Test resolving integration.""" + with pytest.raises(loader.IntegrationNotLoaded): + loader.async_get_loaded_integration(hass, "hue") + integration = await loader.async_get_integration(hass, "hue") assert hue == integration.get_component() assert hue_light == integration.get_platform("light") + integration = loader.async_get_loaded_integration(hass, "hue") + assert hue == integration.get_component() + assert hue_light == integration.get_platform("light") + async def test_get_integration_exceptions(hass: HomeAssistant) -> None: """Test resolving integration."""