diff --git a/.coveragerc b/.coveragerc index 66c47e35f37..533fd8de18d 100644 --- a/.coveragerc +++ b/.coveragerc @@ -29,11 +29,13 @@ omit = homeassistant/components/adguard/switch.py homeassistant/components/ads/* homeassistant/components/aemet/weather_update_coordinator.py - homeassistant/components/aftership/* + homeassistant/components/aftership/__init__.py + homeassistant/components/aftership/sensor.py homeassistant/components/agent_dvr/alarm_control_panel.py homeassistant/components/agent_dvr/camera.py homeassistant/components/agent_dvr/helpers.py homeassistant/components/airnow/__init__.py + homeassistant/components/airnow/coordinator.py homeassistant/components/airnow/sensor.py homeassistant/components/airq/__init__.py homeassistant/components/airq/coordinator.py @@ -44,6 +46,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 @@ -100,6 +103,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 @@ -171,6 +175,7 @@ omit = homeassistant/components/comed_hourly_pricing/sensor.py homeassistant/components/comelit/__init__.py homeassistant/components/comelit/const.py + homeassistant/components/comelit/cover.py homeassistant/components/comelit/coordinator.py homeassistant/components/comelit/light.py homeassistant/components/comfoconnect/fan.py @@ -179,6 +184,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 @@ -242,6 +248,8 @@ omit = homeassistant/components/duotecno/switch.py homeassistant/components/duotecno/cover.py homeassistant/components/duotecno/light.py + homeassistant/components/duotecno/climate.py + homeassistant/components/duotecno/binary_sensor.py homeassistant/components/dwd_weather_warnings/const.py homeassistant/components/dwd_weather_warnings/coordinator.py homeassistant/components/dwd_weather_warnings/sensor.py @@ -255,6 +263,12 @@ 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/number.py + homeassistant/components/ecoforest/sensor.py + homeassistant/components/ecoforest/switch.py homeassistant/components/econet/__init__.py homeassistant/components/econet/binary_sensor.py homeassistant/components/econet/climate.py @@ -276,7 +290,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 @@ -355,6 +368,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 @@ -379,7 +393,6 @@ omit = homeassistant/components/firmata/pin.py homeassistant/components/firmata/sensor.py homeassistant/components/firmata/switch.py - homeassistant/components/fitbit/* homeassistant/components/fivem/__init__.py homeassistant/components/fivem/binary_sensor.py homeassistant/components/fivem/coordinator.py @@ -528,7 +541,12 @@ omit = homeassistant/components/hvv_departures/__init__.py homeassistant/components/hvv_departures/binary_sensor.py homeassistant/components/hvv_departures/sensor.py - homeassistant/components/hydrawise/* + homeassistant/components/hydrawise/__init__.py + homeassistant/components/hydrawise/binary_sensor.py + homeassistant/components/hydrawise/const.py + homeassistant/components/hydrawise/coordinator.py + homeassistant/components/hydrawise/sensor.py + homeassistant/components/hydrawise/switch.py homeassistant/components/ialarm/alarm_control_panel.py homeassistant/components/iammeter/sensor.py homeassistant/components/iaqualink/binary_sensor.py @@ -654,6 +672,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/* @@ -703,11 +722,13 @@ 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 - homeassistant/components/media_extractor/* + homeassistant/components/medcom_ble/__init__.py + homeassistant/components/medcom_ble/sensor.py homeassistant/components/mediaroom/media_player.py homeassistant/components/melcloud/__init__.py homeassistant/components/melcloud/climate.py @@ -731,6 +752,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 @@ -746,7 +768,9 @@ omit = homeassistant/components/moehlenhoff_alpha2/climate.py homeassistant/components/moehlenhoff_alpha2/sensor.py homeassistant/components/motion_blinds/__init__.py + homeassistant/components/motion_blinds/coordinator.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 @@ -790,6 +814,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 @@ -842,6 +867,7 @@ omit = homeassistant/components/obihai/connectivity.py homeassistant/components/obihai/sensor.py homeassistant/components/octoprint/__init__.py + homeassistant/components/octoprint/coordinator.py homeassistant/components/oem/climate.py homeassistant/components/ohmconnect/sensor.py homeassistant/components/ombi/* @@ -872,6 +898,7 @@ omit = homeassistant/components/opengarage/cover.py homeassistant/components/opengarage/entity.py homeassistant/components/opengarage/sensor.py + homeassistant/components/openhardwaremonitor/sensor.py homeassistant/components/openhome/__init__.py homeassistant/components/openhome/const.py homeassistant/components/openhome/media_player.py @@ -953,6 +980,8 @@ omit = homeassistant/components/point/sensor.py homeassistant/components/poolsense/__init__.py homeassistant/components/poolsense/binary_sensor.py + homeassistant/components/poolsense/coordinator.py + homeassistant/components/poolsense/entity.py homeassistant/components/poolsense/sensor.py homeassistant/components/powerwall/__init__.py homeassistant/components/progettihwsw/__init__.py @@ -1003,9 +1032,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 @@ -1066,9 +1099,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 @@ -1132,6 +1166,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 @@ -1148,6 +1183,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 @@ -1240,6 +1276,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 @@ -1262,6 +1301,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 @@ -1273,6 +1313,8 @@ omit = homeassistant/components/tank_utility/sensor.py homeassistant/components/tankerkoenig/__init__.py homeassistant/components/tankerkoenig/binary_sensor.py + homeassistant/components/tankerkoenig/coordinator.py + homeassistant/components/tankerkoenig/entity.py homeassistant/components/tankerkoenig/sensor.py homeassistant/components/tapsaff/binary_sensor.py homeassistant/components/tautulli/__init__.py @@ -1441,9 +1483,11 @@ omit = homeassistant/components/vlc_telnet/__init__.py homeassistant/components/vlc_telnet/media_player.py homeassistant/components/vodafone_station/__init__.py + homeassistant/components/vodafone_station/button.py 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 @@ -1464,11 +1508,15 @@ omit = homeassistant/components/watson_tts/tts.py homeassistant/components/watttime/__init__.py homeassistant/components/watttime/sensor.py + homeassistant/components/weatherflow/__init__.py + homeassistant/components/weatherflow/const.py + homeassistant/components/weatherflow/sensor.py homeassistant/components/wiffi/__init__.py homeassistant/components/wiffi/binary_sensor.py 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 1adcc269eb9..20d158ed676 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.1.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.1.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.1.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 }} @@ -252,7 +252,7 @@ jobs: - green steps: - name: Checkout the repository - uses: actions/checkout@v3.6.0 + uses: actions/checkout@v4.1.0 - name: Set build additional args run: | @@ -266,7 +266,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 }} @@ -289,7 +289,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout the repository - uses: actions/checkout@v3.6.0 + uses: actions/checkout@v4.1.0 - name: Initialize git uses: home-assistant/actions/helpers/git-init@master @@ -327,21 +327,21 @@ jobs: id-token: write steps: - name: Checkout the repository - uses: actions/checkout@v3.6.0 + uses: actions/checkout@v4.1.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 26811f31962..053877b608e 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -35,8 +35,9 @@ on: env: CACHE_VERSION: 5 PIP_CACHE_VERSION: 4 - MYPY_CACHE_VERSION: 4 - HA_SHORT_VERSION: 2023.9 + MYPY_CACHE_VERSION: 5 + BLACK_CACHE_VERSION: 1 + HA_SHORT_VERSION: "2023.10" DEFAULT_PYTHON: "3.11" ALL_PYTHON_VERSIONS: "['3.11']" # 10.3 is the oldest supported version @@ -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.1.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.1.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.1.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.1.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.1.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.1.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.1.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.1.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.1.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.1.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.1.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.1.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.1.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.1.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 5fb977f74d1..c91117cb02d 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 5affa459f52..84d7fc03e43 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.1.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 c6f819f9dfd..85912623f61 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.1.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.1.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.1.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;charset-normalizer;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;charset-normalizer;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;charset-normalizer;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 77740d6279e..b0c98143300 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 e8bca0a1abd..6b2c52f42f6 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.* @@ -209,10 +214,12 @@ homeassistant.components.local_ip.* homeassistant.components.lock.* homeassistant.components.logbook.* homeassistant.components.logger.* +homeassistant.components.london_underground.* 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 +260,10 @@ homeassistant.components.peco.* homeassistant.components.persistent_notification.* homeassistant.components.pi_hole.* homeassistant.components.ping.* +homeassistant.components.plugwise.* +homeassistant.components.poolsense.* homeassistant.components.powerwall.* +homeassistant.components.private_ble_device.* homeassistant.components.proximity.* homeassistant.components.prusalink.* homeassistant.components.pure_energie.* @@ -311,6 +321,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 +343,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 2d28671fce5..eed0f633df3 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 @@ -354,8 +360,8 @@ build.json @home-assistant/supervisor /homeassistant/components/eq3btsmart/ @rytilahti /homeassistant/components/escea/ @lazdavila /tests/components/escea/ @lazdavila -/homeassistant/components/esphome/ @OttoWinter @jesserockz @bdraco -/tests/components/esphome/ @OttoWinter @jesserockz @bdraco +/homeassistant/components/esphome/ @OttoWinter @jesserockz @kbx81 @bdraco +/tests/components/esphome/ @OttoWinter @jesserockz @kbx81 @bdraco /homeassistant/components/eufylife_ble/ @bdr99 /tests/components/eufylife_ble/ @bdr99 /homeassistant/components/event/ @home-assistant/core @@ -384,6 +390,8 @@ build.json @home-assistant/supervisor /tests/components/fireservicerota/ @cyberjunky /homeassistant/components/firmata/ @DaAwesomeP /tests/components/firmata/ @DaAwesomeP +/homeassistant/components/fitbit/ @allenporter +/tests/components/fitbit/ @allenporter /homeassistant/components/fivem/ @Sander0542 /tests/components/fivem/ @Sander0542 /homeassistant/components/fjaraskupan/ @elupus @@ -396,8 +404,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 @@ -554,6 +562,7 @@ build.json @home-assistant/supervisor /homeassistant/components/hvv_departures/ @vigonotion /tests/components/hvv_departures/ @vigonotion /homeassistant/components/hydrawise/ @dknowles2 @ptcryan +/tests/components/hydrawise/ @dknowles2 @ptcryan /homeassistant/components/hyperion/ @dermotduffy /tests/components/hyperion/ @dermotduffy /homeassistant/components/ialarm/ @RyuzakiKK @@ -565,6 +574,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 +695,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 +716,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,13 +734,18 @@ 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 /tests/components/mazda/ @bdr99 /homeassistant/components/meater/ @Sotolotl @emontnemery /tests/components/meater/ @Sotolotl @emontnemery +/homeassistant/components/medcom_ble/ @elafargue +/tests/components/medcom_ble/ @elafargue /homeassistant/components/media_extractor/ @joostlek +/tests/components/media_extractor/ @joostlek /homeassistant/components/media_player/ @home-assistant/core /tests/components/media_player/ @home-assistant/core /homeassistant/components/media_source/ @hunterjm @@ -766,8 +782,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 @@ -793,8 +809,8 @@ build.json @home-assistant/supervisor /tests/components/mutesync/ @currentoor /homeassistant/components/my/ @home-assistant/core /tests/components/my/ @home-assistant/core -/homeassistant/components/myq/ @ehendrix23 -/tests/components/myq/ @ehendrix23 +/homeassistant/components/myq/ @ehendrix23 @Lash-L +/tests/components/myq/ @ehendrix23 @Lash-L /homeassistant/components/mysensors/ @MartinHjelmare @functionpointer /tests/components/mysensors/ @MartinHjelmare @functionpointer /homeassistant/components/mystrom/ @fabaff @@ -949,6 +965,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 +1075,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 +1153,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 +1202,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 +1247,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 +1329,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 +1374,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 +1406,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 +1417,10 @@ 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/weatherflow/ @natekspencer @jeeftor +/tests/components/weatherflow/ @natekspencer @jeeftor +/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 +1438,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 +1473,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/Dockerfile b/Dockerfile index e229f27cb33..f2a365b2b8a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,9 +15,8 @@ COPY homeassistant/package_constraints.txt homeassistant/homeassistant/ RUN \ pip3 install \ --no-cache-dir \ - --no-index \ --only-binary=:all: \ - --find-links "${WHEELS_LINKS}" \ + --index-url "https://wheels.home-assistant.io/musllinux-index/" \ -r homeassistant/requirements.txt COPY requirements_all.txt home_assistant_frontend-* home_assistant_intents-* homeassistant/ @@ -39,9 +38,8 @@ RUN \ MALLOC_CONF="background_thread:true,metadata_thp:auto,dirty_decay_ms:20000,muzzy_decay_ms:20000" \ pip3 install \ --no-cache-dir \ - --no-index \ --only-binary=:all: \ - --find-links "${WHEELS_LINKS}" \ + --index-url "https://wheels.home-assistant.io/musllinux-index/" \ -r homeassistant/requirements_all.txt ## Setup Home Assistant Core @@ -49,9 +47,8 @@ COPY . homeassistant/ RUN \ pip3 install \ --no-cache-dir \ - --no-index \ --only-binary=:all: \ - --find-links "${WHEELS_LINKS}" \ + --index-url "https://wheels.home-assistant.io/musllinux-index/" \ -e ./homeassistant \ && python3 -m compileall \ homeassistant/homeassistant diff --git a/build.yaml b/build.yaml index cc13a4e595f..f9e19f89e23 100644 --- a/build.yaml +++ b/build.yaml @@ -1,10 +1,10 @@ image: ghcr.io/home-assistant/{arch}-homeassistant build_from: - aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2023.08.0 - armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2023.08.0 - armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2023.08.0 - amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2023.08.0 - i386: ghcr.io/home-assistant/i386-homeassistant-base:2023.08.0 + aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2023.09.0 + armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2023.09.0 + armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2023.09.0 + amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2023.09.0 + i386: ghcr.io/home-assistant/i386-homeassistant-base:2023.09.0 codenotary: signer: notary@home-assistant.io base_image: notary@home-assistant.io diff --git a/homeassistant/brands/apple.json b/homeassistant/brands/apple.json index 00f646e435e..b0b66de0bcc 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 702a59ad4d1..dee69001add 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 00000000000..0909b24a146 --- /dev/null +++ b/homeassistant/brands/switchbot.json @@ -0,0 +1,5 @@ +{ + "domain": "switchbot", + "name": "SwitchBot", + "integrations": ["switchbot", "switchbot_cloud"] +} diff --git a/homeassistant/brands/u_tec.json b/homeassistant/brands/u_tec.json index 2ce4be9a7d9..f0c2cf8a691 100644 --- a/homeassistant/brands/u_tec.json +++ b/homeassistant/brands/u_tec.json @@ -1,5 +1,5 @@ { "domain": "u_tec", "name": "U-tec", - "iot_standards": ["zwave"] + "integrations": ["ultraloq"] } diff --git a/homeassistant/components/advantage_air/climate.py b/homeassistant/components/advantage_air/climate.py index fa9f609ba10..cda123f62ee 100644 --- a/homeassistant/components/advantage_air/climate.py +++ b/homeassistant/components/advantage_air/climate.py @@ -125,6 +125,13 @@ class AdvantageAirAC(AdvantageAirAcEntity, ClimateEntity): @property def target_temperature(self) -> float | None: """Return the current target temperature.""" + # If the system is in MyZone mode, and a zone is set, return that temperature instead. + if ( + self._ac["myZone"] > 0 + and not self._ac.get(ADVANTAGE_AIR_MYAUTO_ENABLED) + and not self._ac.get(ADVANTAGE_AIR_MYTEMP_ENABLED) + ): + return self._myzone["setTemp"] return self._ac["setTemp"] @property diff --git a/homeassistant/components/advantage_air/entity.py b/homeassistant/components/advantage_air/entity.py index 00750fb4e94..b300a677793 100644 --- a/homeassistant/components/advantage_air/entity.py +++ b/homeassistant/components/advantage_air/entity.py @@ -62,6 +62,12 @@ class AdvantageAirAcEntity(AdvantageAirEntity): def _ac(self) -> dict[str, Any]: return self.coordinator.data["aircons"][self.ac_key]["info"] + @property + def _myzone(self) -> dict[str, Any]: + return self.coordinator.data["aircons"][self.ac_key]["zones"].get( + f"z{self._ac['myZone']:02}" + ) + class AdvantageAirZoneEntity(AdvantageAirAcEntity): """Parent class for Advantage Air Zone Entities.""" diff --git a/homeassistant/components/aemet/__init__.py b/homeassistant/components/aemet/__init__.py index c8b3f774a97..bcddce5868c 100644 --- a/homeassistant/components/aemet/__init__.py +++ b/homeassistant/components/aemet/__init__.py @@ -30,7 +30,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: longitude = entry.data[CONF_LONGITUDE] station_updates = entry.options.get(CONF_STATION_UPDATES, True) - options = ConnectionOptions(api_key, station_updates) + options = ConnectionOptions(api_key, station_updates, True) aemet = AEMET(aiohttp_client.async_get_clientsession(hass), options) try: await aemet.select_coordinates(latitude, longitude) diff --git a/homeassistant/components/aemet/config_flow.py b/homeassistant/components/aemet/config_flow.py index 4df25613803..dbf3df823e3 100644 --- a/homeassistant/components/aemet/config_flow.py +++ b/homeassistant/components/aemet/config_flow.py @@ -40,7 +40,7 @@ class AemetConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): await self.async_set_unique_id(f"{latitude}-{longitude}") self._abort_if_unique_id_configured() - options = ConnectionOptions(user_input[CONF_API_KEY], False) + options = ConnectionOptions(user_input[CONF_API_KEY], False, True) aemet = AEMET(aiohttp_client.async_get_clientsession(self.hass), options) try: await aemet.select_coordinates(latitude, longitude) diff --git a/homeassistant/components/aemet/const.py b/homeassistant/components/aemet/const.py index c6c4a9c1628..7940ff92f72 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_MAX_SPEED = "wind-max-speed" 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_ATTR_API = { 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/manifest.json b/homeassistant/components/aemet/manifest.json index 1c65572a64e..74d53cc117a 100644 --- a/homeassistant/components/aemet/manifest.json +++ b/homeassistant/components/aemet/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/aemet", "iot_class": "cloud_polling", "loggers": ["aemet_opendata"], - "requirements": ["AEMET-OpenData==0.4.4"] + "requirements": ["AEMET-OpenData==0.4.5"] } diff --git a/homeassistant/components/aemet/sensor.py b/homeassistant/components/aemet/sensor.py index f7aa6b35893..76e691a4682 100644 --- a/homeassistant/components/aemet/sensor.py +++ b/homeassistant/components/aemet/sensor.py @@ -30,6 +30,7 @@ from .const import ( 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 @@ FORECAST_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( 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 @@ WEATHER_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( 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 e3a1922c2f1..03f91a74740 100644 --- a/homeassistant/components/aemet/weather.py +++ b/homeassistant/components/aemet/weather.py @@ -42,6 +42,7 @@ from .const import ( 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 @@ class AemetWeather(SingleCoordinatorWeatherEntity[WeatherUpdateCoordinator]): """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 c6e27374f8f..01c2502fb37 100644 --- a/homeassistant/components/aemet/weather_update_coordinator.py +++ b/homeassistant/components/aemet/weather_update_coordinator.py @@ -34,6 +34,7 @@ from aemet_opendata.const import ( 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 @@ from .const import ( ATTR_API_WIND_SPEED, CONDITIONS_MAP, DOMAIN, - WIND_BEARING_MAP, ) _LOGGER = logging.getLogger(__name__) @@ -90,11 +90,8 @@ WEATHER_UPDATE_INTERVAL = timedelta(minutes=10) 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 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): 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 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): 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/aftership/__init__.py b/homeassistant/components/aftership/__init__.py index b063c919f18..66610e6e01b 100644 --- a/homeassistant/components/aftership/__init__.py +++ b/homeassistant/components/aftership/__init__.py @@ -1 +1,42 @@ -"""The aftership component.""" +"""The AfterShip integration.""" +from __future__ import annotations + +from pyaftership import AfterShip, AfterShipException + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up AfterShip from a config entry.""" + + hass.data.setdefault(DOMAIN, {}) + + session = async_get_clientsession(hass) + aftership = AfterShip(api_key=entry.data[CONF_API_KEY], session=session) + + try: + await aftership.trackings.list() + except AfterShipException as err: + raise ConfigEntryNotReady from err + + hass.data[DOMAIN][entry.entry_id] = aftership + + 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/aftership/config_flow.py b/homeassistant/components/aftership/config_flow.py new file mode 100644 index 00000000000..3da6ac9e3d5 --- /dev/null +++ b/homeassistant/components/aftership/config_flow.py @@ -0,0 +1,90 @@ +"""Config flow for AfterShip integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from pyaftership import AfterShip, AfterShipException +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_API_KEY, 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 .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class AfterShipConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for AfterShip.""" + + 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: + self._async_abort_entries_match({CONF_API_KEY: user_input[CONF_API_KEY]}) + try: + aftership = AfterShip( + api_key=user_input[CONF_API_KEY], + session=async_get_clientsession(self.hass), + ) + await aftership.trackings.list() + except AfterShipException: + _LOGGER.exception("Aftership raised exception") + errors["base"] = "cannot_connect" + else: + return self.async_create_entry(title="AfterShip", data=user_input) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({vol.Required(CONF_API_KEY): str}), + errors=errors, + ) + + async def async_step_import(self, config: dict[str, Any]) -> FlowResult: + """Import configuration from yaml.""" + try: + self._async_abort_entries_match({CONF_API_KEY: config[CONF_API_KEY]}) + except AbortFlow as err: + async_create_issue( + self.hass, + DOMAIN, + "deprecated_yaml_import_issue_already_configured", + breaks_in_ha_version="2024.4.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml_import_issue_already_configured", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "AfterShip", + }, + ) + raise err + + 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": "AfterShip", + }, + ) + return self.async_create_entry( + title=config.get(CONF_NAME, "AfterShip"), + data={CONF_API_KEY: config[CONF_API_KEY]}, + ) diff --git a/homeassistant/components/aftership/manifest.json b/homeassistant/components/aftership/manifest.json index 1cfc88a6f9d..eb4fffa57bc 100644 --- a/homeassistant/components/aftership/manifest.json +++ b/homeassistant/components/aftership/manifest.json @@ -2,6 +2,7 @@ "domain": "aftership", "name": "AfterShip", "codeowners": [], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/aftership", "iot_class": "cloud_polling", "requirements": ["pyaftership==21.11.0"] diff --git a/homeassistant/components/aftership/sensor.py b/homeassistant/components/aftership/sensor.py index d816afa3b17..a3b85f2188d 100644 --- a/homeassistant/components/aftership/sensor.py +++ b/homeassistant/components/aftership/sensor.py @@ -11,6 +11,7 @@ from homeassistant.components.sensor import ( PLATFORM_SCHEMA as BASE_PLATFORM_SCHEMA, SensorEntity, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_NAME from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -20,6 +21,7 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_send, ) 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 import Throttle @@ -58,19 +60,43 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the AfterShip sensor platform.""" - apikey = config[CONF_API_KEY] - name = config[CONF_NAME] - - session = async_get_clientsession(hass) - aftership = AfterShip(api_key=apikey, session=session) - + aftership = AfterShip( + api_key=config[CONF_API_KEY], session=async_get_clientsession(hass) + ) try: await aftership.trackings.list() - except AfterShipException as err: - _LOGGER.error("No tracking data found. Check API key is correct: %s", err) - return + except AfterShipException: + 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={ + "integration_title": "AfterShip", + "url": "/config/integrations/dashboard/add?domain=aftership", + }, + ) - async_add_entities([AfterShipSensor(aftership, name)], True) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config + ) + ) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up AfterShip sensor entities based on a config entry.""" + aftership: AfterShip = hass.data[DOMAIN][config_entry.entry_id] + + async_add_entities([AfterShipSensor(aftership, config_entry.title)], True) async def handle_add_tracking(call: ServiceCall) -> None: """Call when a user adds a new Aftership tracking from Home Assistant.""" diff --git a/homeassistant/components/aftership/strings.json b/homeassistant/components/aftership/strings.json index a7ccdd48202..b49c19976a6 100644 --- a/homeassistant/components/aftership/strings.json +++ b/homeassistant/components/aftership/strings.json @@ -1,4 +1,19 @@ { + "config": { + "step": { + "user": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, "services": { "add_tracking": { "name": "Add tracking", @@ -32,5 +47,15 @@ } } } + }, + "issues": { + "deprecated_yaml_import_issue_already_configured": { + "title": "The {integration_title} YAML configuration import failed", + "description": "Configuring {integration_title} using YAML is being removed but the YAML configuration was already imported.\n\nRemove the YAML configuration and restart Home Assistant." + }, + "deprecated_yaml_import_issue_cannot_connect": { + "title": "The {integration_title} YAML configuration import failed", + "description": "Configuring {integration_title} using YAML is being removed but there was an connection error importing your YAML configuration.\n\nEnsure connection to {integration_title} works and restart Home Assistant to try again or remove the {integration_title} YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." + } } } diff --git a/homeassistant/components/airly/__init__.py b/homeassistant/components/airly/__init__.py index 982687c7723..91208de519b 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.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, Pla 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 00000000000..9f2a1c96511 --- /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/__init__.py b/homeassistant/components/airnow/__init__.py index c4d52c6ac8e..8fe2291d3b3 100644 --- a/homeassistant/components/airnow/__init__.py +++ b/homeassistant/components/airnow/__init__.py @@ -2,11 +2,6 @@ import datetime import logging -from aiohttp.client_exceptions import ClientConnectorError -from pyairnow import WebServiceAPI -from pyairnow.conv import aqi_to_concentration -from pyairnow.errors import AirNowError - from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_API_KEY, @@ -17,26 +12,9 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import ( - ATTR_API_AQI, - ATTR_API_AQI_DESCRIPTION, - ATTR_API_AQI_LEVEL, - ATTR_API_AQI_PARAM, - ATTR_API_CAT_DESCRIPTION, - ATTR_API_CAT_LEVEL, - ATTR_API_CATEGORY, - ATTR_API_PM25, - ATTR_API_POLLUTANT, - ATTR_API_REPORT_DATE, - ATTR_API_REPORT_HOUR, - ATTR_API_STATE, - ATTR_API_STATION, - ATTR_API_STATION_LATITUDE, - ATTR_API_STATION_LONGITUDE, - DOMAIN, -) +from .const import DOMAIN +from .coordinator import AirNowDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.SENSOR] @@ -107,72 +85,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) - - -class AirNowDataUpdateCoordinator(DataUpdateCoordinator): - """Define an object to hold Airly data.""" - - def __init__( - self, hass, session, api_key, latitude, longitude, distance, update_interval - ): - """Initialize.""" - self.latitude = latitude - self.longitude = longitude - self.distance = distance - - self.airnow = WebServiceAPI(api_key, session=session) - - super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=update_interval) - - async def _async_update_data(self): - """Update data via library.""" - data = {} - try: - obs = await self.airnow.observations.latLong( - self.latitude, - self.longitude, - distance=self.distance, - ) - - except (AirNowError, ClientConnectorError) as error: - raise UpdateFailed(error) from error - - if not obs: - raise UpdateFailed("No data was returned from AirNow") - - max_aqi = 0 - max_aqi_level = 0 - max_aqi_desc = "" - max_aqi_poll = "" - for obv in obs: - # Convert AQIs to Concentration - pollutant = obv[ATTR_API_AQI_PARAM] - concentration = aqi_to_concentration(obv[ATTR_API_AQI], pollutant) - data[obv[ATTR_API_AQI_PARAM]] = concentration - - # Overall AQI is the max of all pollutant AQIs - if obv[ATTR_API_AQI] > max_aqi: - max_aqi = obv[ATTR_API_AQI] - max_aqi_level = obv[ATTR_API_CATEGORY][ATTR_API_CAT_LEVEL] - max_aqi_desc = obv[ATTR_API_CATEGORY][ATTR_API_CAT_DESCRIPTION] - max_aqi_poll = pollutant - - # Copy other data from PM2.5 Value - if obv[ATTR_API_AQI_PARAM] == ATTR_API_PM25: - # Copy Report Details - data[ATTR_API_REPORT_DATE] = obv[ATTR_API_REPORT_DATE] - data[ATTR_API_REPORT_HOUR] = obv[ATTR_API_REPORT_HOUR] - - # Copy Station Details - data[ATTR_API_STATE] = obv[ATTR_API_STATE] - data[ATTR_API_STATION] = obv[ATTR_API_STATION] - data[ATTR_API_STATION_LATITUDE] = obv[ATTR_API_STATION_LATITUDE] - data[ATTR_API_STATION_LONGITUDE] = obv[ATTR_API_STATION_LONGITUDE] - - # Store Overall AQI - data[ATTR_API_AQI] = max_aqi - data[ATTR_API_AQI_LEVEL] = max_aqi_level - data[ATTR_API_AQI_DESCRIPTION] = max_aqi_desc - data[ATTR_API_POLLUTANT] = max_aqi_poll - - return data diff --git a/homeassistant/components/airnow/coordinator.py b/homeassistant/components/airnow/coordinator.py new file mode 100644 index 00000000000..7a4ad46cd82 --- /dev/null +++ b/homeassistant/components/airnow/coordinator.py @@ -0,0 +1,99 @@ +"""DataUpdateCoordinator for the AirNow integration.""" +import logging + +from aiohttp.client_exceptions import ClientConnectorError +from pyairnow import WebServiceAPI +from pyairnow.conv import aqi_to_concentration +from pyairnow.errors import AirNowError + +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import ( + ATTR_API_AQI, + ATTR_API_AQI_DESCRIPTION, + ATTR_API_AQI_LEVEL, + ATTR_API_AQI_PARAM, + ATTR_API_CAT_DESCRIPTION, + ATTR_API_CAT_LEVEL, + ATTR_API_CATEGORY, + ATTR_API_PM25, + ATTR_API_POLLUTANT, + ATTR_API_REPORT_DATE, + ATTR_API_REPORT_HOUR, + ATTR_API_STATE, + ATTR_API_STATION, + ATTR_API_STATION_LATITUDE, + ATTR_API_STATION_LONGITUDE, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + + +class AirNowDataUpdateCoordinator(DataUpdateCoordinator): + """The AirNow update coordinator.""" + + def __init__( + self, hass, session, api_key, latitude, longitude, distance, update_interval + ): + """Initialize.""" + self.latitude = latitude + self.longitude = longitude + self.distance = distance + + self.airnow = WebServiceAPI(api_key, session=session) + + super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=update_interval) + + async def _async_update_data(self): + """Update data via library.""" + data = {} + try: + obs = await self.airnow.observations.latLong( + self.latitude, + self.longitude, + distance=self.distance, + ) + + except (AirNowError, ClientConnectorError) as error: + raise UpdateFailed(error) from error + + if not obs: + raise UpdateFailed("No data was returned from AirNow") + + max_aqi = 0 + max_aqi_level = 0 + max_aqi_desc = "" + max_aqi_poll = "" + for obv in obs: + # Convert AQIs to Concentration + pollutant = obv[ATTR_API_AQI_PARAM] + concentration = aqi_to_concentration(obv[ATTR_API_AQI], pollutant) + data[obv[ATTR_API_AQI_PARAM]] = concentration + + # Overall AQI is the max of all pollutant AQIs + if obv[ATTR_API_AQI] > max_aqi: + max_aqi = obv[ATTR_API_AQI] + max_aqi_level = obv[ATTR_API_CATEGORY][ATTR_API_CAT_LEVEL] + max_aqi_desc = obv[ATTR_API_CATEGORY][ATTR_API_CAT_DESCRIPTION] + max_aqi_poll = pollutant + + # Copy other data from PM2.5 Value + if obv[ATTR_API_AQI_PARAM] == ATTR_API_PM25: + # Copy Report Details + data[ATTR_API_REPORT_DATE] = obv[ATTR_API_REPORT_DATE] + data[ATTR_API_REPORT_HOUR] = obv[ATTR_API_REPORT_HOUR] + + # Copy Station Details + data[ATTR_API_STATE] = obv[ATTR_API_STATE] + data[ATTR_API_STATION] = obv[ATTR_API_STATION] + data[ATTR_API_STATION_LATITUDE] = obv[ATTR_API_STATION_LATITUDE] + data[ATTR_API_STATION_LONGITUDE] = obv[ATTR_API_STATION_LONGITUDE] + + # Store Overall AQI + data[ATTR_API_AQI] = max_aqi + data[ATTR_API_AQI_LEVEL] = max_aqi_level + data[ATTR_API_AQI_DESCRIPTION] = max_aqi_desc + data[ATTR_API_POLLUTANT] = max_aqi_poll + + return data diff --git a/homeassistant/components/airthings_ble/manifest.json b/homeassistant/components/airthings_ble/manifest.json index ef9ad3a802e..cb7114ff8ff 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/airtouch4/__init__.py b/homeassistant/components/airtouch4/__init__.py index a2c3f716ab1..dc5172096a7 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 00000000000..e78bf62dbd0 --- /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 e845c278a54..8a1f947af64 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 21be2e5d664..1403cc94346 100644 --- a/homeassistant/components/airvisual/__init__.py +++ b/homeassistant/components/airvisual/__init__.py @@ -421,8 +421,10 @@ class AirVisualEntity(CoordinatorEntity): self._entry = entry self.entity_description = description + # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: """Register callbacks.""" + await super().async_added_to_hass() @callback def update() -> None: diff --git a/homeassistant/components/airzone/__init__.py b/homeassistant/components/airzone/__init__.py index de75bf03d45..1a54be0ac41 100644 --- a/homeassistant/components/airzone/__init__.py +++ b/homeassistant/components/airzone/__init__.py @@ -24,6 +24,7 @@ PLATFORMS: list[Platform] = [ 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 267cd210ff0..2310d5fb5a4 100644 --- a/homeassistant/components/airzone/entity.py +++ b/homeassistant/components/airzone/entity.py @@ -106,6 +106,22 @@ class AirzoneHotWaterEntity(AirzoneEntity): """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/water_heater.py b/homeassistant/components/airzone/water_heater.py new file mode 100644 index 00000000000..b19aa36449c --- /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/airzone_cloud/__init__.py b/homeassistant/components/airzone_cloud/__init__.py index 732f159c381..38c764d4889 100644 --- a/homeassistant/components/airzone_cloud/__init__.py +++ b/homeassistant/components/airzone_cloud/__init__.py @@ -14,6 +14,7 @@ from .coordinator import AirzoneUpdateCoordinator PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, + Platform.CLIMATE, Platform.SENSOR, ] diff --git a/homeassistant/components/airzone_cloud/climate.py b/homeassistant/components/airzone_cloud/climate.py new file mode 100644 index 00000000000..18393031ae3 --- /dev/null +++ b/homeassistant/components/airzone_cloud/climate.py @@ -0,0 +1,208 @@ +"""Support for the Airzone Cloud climate.""" +from __future__ import annotations + +from typing import Any, Final + +from aioairzone_cloud.common import OperationAction, OperationMode, TemperatureUnit +from aioairzone_cloud.const import ( + API_MODE, + API_OPTS, + API_POWER, + API_SETPOINT, + API_UNITS, + API_VALUE, + AZD_ACTION, + AZD_HUMIDITY, + AZD_MASTER, + AZD_MODE, + AZD_MODES, + AZD_POWER, + AZD_TEMP, + AZD_TEMP_SET, + AZD_TEMP_SET_MAX, + AZD_TEMP_SET_MIN, + AZD_TEMP_STEP, + AZD_ZONES, +) + +from homeassistant.components.climate import ( + ClimateEntity, + ClimateEntityFeature, + HVACAction, + HVACMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import AirzoneUpdateCoordinator +from .entity import AirzoneEntity, AirzoneZoneEntity + +HVAC_ACTION_LIB_TO_HASS: Final[dict[OperationAction, HVACAction]] = { + OperationAction.COOLING: HVACAction.COOLING, + OperationAction.DRYING: HVACAction.DRYING, + OperationAction.FAN: HVACAction.FAN, + OperationAction.HEATING: HVACAction.HEATING, + OperationAction.IDLE: HVACAction.IDLE, + OperationAction.OFF: HVACAction.OFF, +} +HVAC_MODE_LIB_TO_HASS: Final[dict[OperationMode, HVACMode]] = { + OperationMode.STOP: HVACMode.OFF, + OperationMode.COOLING: HVACMode.COOL, + OperationMode.COOLING_AIR: HVACMode.COOL, + OperationMode.COOLING_RADIANT: HVACMode.COOL, + OperationMode.COOLING_COMBINED: HVACMode.COOL, + OperationMode.HEATING: HVACMode.HEAT, + OperationMode.HEAT_AIR: HVACMode.HEAT, + OperationMode.HEAT_RADIANT: HVACMode.HEAT, + OperationMode.HEAT_COMBINED: HVACMode.HEAT, + OperationMode.EMERGENCY_HEAT: HVACMode.HEAT, + OperationMode.VENTILATION: HVACMode.FAN_ONLY, + OperationMode.DRY: HVACMode.DRY, + OperationMode.AUTO: HVACMode.HEAT_COOL, +} +HVAC_MODE_HASS_TO_LIB: Final[dict[HVACMode, OperationMode]] = { + HVACMode.OFF: OperationMode.STOP, + HVACMode.COOL: OperationMode.COOLING, + HVACMode.HEAT: OperationMode.HEATING, + HVACMode.FAN_ONLY: OperationMode.VENTILATION, + HVACMode.DRY: OperationMode.DRY, + HVACMode.HEAT_COOL: OperationMode.AUTO, +} + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Add Airzone climate from a config_entry.""" + coordinator: AirzoneUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + + entities: list[AirzoneClimate] = [] + + # Zones + for zone_id, zone_data in coordinator.data.get(AZD_ZONES, {}).items(): + entities.append( + AirzoneZoneClimate( + coordinator, + zone_id, + zone_data, + ) + ) + + async_add_entities(entities) + + +class AirzoneClimate(AirzoneEntity, ClimateEntity): + """Define an Airzone Cloud climate.""" + + _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _attr_temperature_unit = UnitOfTemperature.CELSIUS + + async def async_turn_on(self) -> None: + """Turn the entity on.""" + params = { + API_POWER: { + API_VALUE: True, + }, + } + await self._async_update_params(params) + + async def async_turn_off(self) -> None: + """Turn the entity off.""" + params = { + API_POWER: { + API_VALUE: False, + }, + } + await self._async_update_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_SETPOINT] = { + API_VALUE: kwargs[ATTR_TEMPERATURE], + API_OPTS: { + API_UNITS: TemperatureUnit.CELSIUS.value, + }, + } + await self._async_update_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 climate attributes.""" + self._attr_current_temperature = self.get_airzone_value(AZD_TEMP) + self._attr_current_humidity = self.get_airzone_value(AZD_HUMIDITY) + self._attr_hvac_action = HVAC_ACTION_LIB_TO_HASS[ + self.get_airzone_value(AZD_ACTION) + ] + if self.get_airzone_value(AZD_POWER): + self._attr_hvac_mode = HVAC_MODE_LIB_TO_HASS[ + self.get_airzone_value(AZD_MODE) + ] + else: + self._attr_hvac_mode = HVACMode.OFF + self._attr_max_temp = self.get_airzone_value(AZD_TEMP_SET_MAX) + self._attr_min_temp = self.get_airzone_value(AZD_TEMP_SET_MIN) + self._attr_target_temperature = self.get_airzone_value(AZD_TEMP_SET) + + +class AirzoneZoneClimate(AirzoneZoneEntity, AirzoneClimate): + """Define an Airzone Cloud Zone climate.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: AirzoneUpdateCoordinator, + system_zone_id: str, + zone_data: dict, + ) -> None: + """Initialize Airzone Cloud Zone climate.""" + super().__init__(coordinator, system_zone_id, zone_data) + + self._attr_unique_id = system_zone_id + self._attr_target_temperature_step = self.get_airzone_value(AZD_TEMP_STEP) + self._attr_hvac_modes = [ + HVAC_MODE_LIB_TO_HASS[mode] for mode in self.get_airzone_value(AZD_MODES) + ] + if HVACMode.OFF not in self._attr_hvac_modes: + self._attr_hvac_modes += [HVACMode.OFF] + + self._async_update_attrs() + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set hvac mode.""" + slave_raise = False + + params: dict[str, Any] = {} + if hvac_mode == HVACMode.OFF: + params[API_POWER] = { + API_VALUE: False, + } + else: + mode = HVAC_MODE_HASS_TO_LIB[hvac_mode] + if mode != self.get_airzone_value(AZD_MODE): + if self.get_airzone_value(AZD_MASTER): + params[API_MODE] = { + API_VALUE: mode.value, + } + else: + slave_raise = True + params[API_POWER] = { + API_VALUE: True, + } + + await self._async_update_params(params) + + if slave_raise: + raise HomeAssistantError(f"Mode can't be changed on slave zone {self.name}") diff --git a/homeassistant/components/airzone_cloud/entity.py b/homeassistant/components/airzone_cloud/entity.py index 090e81e4170..3214869aaab 100644 --- a/homeassistant/components/airzone_cloud/entity.py +++ b/homeassistant/components/airzone_cloud/entity.py @@ -2,6 +2,7 @@ from __future__ import annotations from abc import ABC, abstractmethod +import logging from typing import Any from aioairzone_cloud.const import ( @@ -15,7 +16,9 @@ from aioairzone_cloud.const import ( AZD_WEBSERVERS, AZD_ZONES, ) +from aioairzone_cloud.exceptions import AirzoneCloudError +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -23,6 +26,8 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, MANUFACTURER from .coordinator import AirzoneUpdateCoordinator +_LOGGER = logging.getLogger(__name__) + class AirzoneEntity(CoordinatorEntity[AirzoneUpdateCoordinator], ABC): """Define an Airzone Cloud entity.""" @@ -36,6 +41,10 @@ class AirzoneEntity(CoordinatorEntity[AirzoneUpdateCoordinator], ABC): def get_airzone_value(self, key: str) -> Any: """Return Airzone Cloud entity value by key.""" + async def _async_update_params(self, params: dict[str, Any]) -> None: + """Send Airzone parameters to Cloud API.""" + raise NotImplementedError + class AirzoneAidooEntity(AirzoneEntity): """Define an Airzone Cloud Aidoo entity.""" @@ -153,3 +162,15 @@ class AirzoneZoneEntity(AirzoneEntity): if zone := self.coordinator.data[AZD_ZONES].get(self.zone_id): value = zone.get(key) return value + + async def _async_update_params(self, params: dict[str, Any]) -> None: + """Send Zone parameters to Cloud API.""" + _LOGGER.debug("zone=%s: update_params=%s", self.name, params) + try: + await self.coordinator.airzone.api_set_zone_id_params(self.zone_id, params) + except AirzoneCloudError as error: + raise HomeAssistantError( + f"Failed to set {self.name} params: {error}" + ) from error + + self.coordinator.async_set_updated_data(self.coordinator.airzone.data()) diff --git a/homeassistant/components/airzone_cloud/manifest.json b/homeassistant/components/airzone_cloud/manifest.json index 289565f0473..1a158fcd1fe 100644 --- a/homeassistant/components/airzone_cloud/manifest.json +++ b/homeassistant/components/airzone_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone_cloud", "iot_class": "cloud_polling", "loggers": ["aioairzone_cloud"], - "requirements": ["aioairzone-cloud==0.2.1"] + "requirements": ["aioairzone-cloud==0.2.3"] } diff --git a/homeassistant/components/aladdin_connect/cover.py b/homeassistant/components/aladdin_connect/cover.py index f466f5f4248..604ac61300d 100644 --- a/homeassistant/components/aladdin_connect/cover.py +++ b/homeassistant/components/aladdin_connect/cover.py @@ -10,7 +10,7 @@ from homeassistant.components.cover import CoverDeviceClass, CoverEntity 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 @@ class AladdinDevice(CoverEntity): 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 00000000000..c49d321631e --- /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 3f31a833f1a..83f8e0167e8 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 7f6331515c6..da0bd8b36aa 100644 --- a/homeassistant/components/alexa/entities.py +++ b/homeassistant/components/alexa/entities.py @@ -707,7 +707,8 @@ class MediaPlayerCapabilities(AlexaEntity): # 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/state_report.py b/homeassistant/components/alexa/state_report.py index 786b2ee5227..f1cf13a0a7e 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 75d12a3271c..8b8d87092c4 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 f782db79879..b8c020e6e1e 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/anthemav/config_flow.py b/homeassistant/components/anthemav/config_flow.py index 23694654eb3..e75c67cb2c5 100644 --- a/homeassistant/components/anthemav/config_flow.py +++ b/homeassistant/components/anthemav/config_flow.py @@ -9,8 +9,8 @@ from anthemav.connection import Connection from anthemav.device_error import DeviceError import voluptuous as vol -from homeassistant import config_entries -from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_PORT +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PORT from homeassistant.data_entry_flow import FlowResult import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import format_mac @@ -43,7 +43,7 @@ async def connect_device(user_input: dict[str, Any]) -> Connection: return avr -class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): +class AnthemAVConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Anthem A/V Receivers.""" VERSION = 1 @@ -57,9 +57,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="user", data_schema=STEP_USER_DATA_SCHEMA ) - if CONF_NAME not in user_input: - user_input[CONF_NAME] = DEFAULT_NAME - errors = {} avr: Connection | None = None @@ -84,7 +81,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): user_input[CONF_MODEL] = avr.protocol.model await self.async_set_unique_id(user_input[CONF_MAC]) self._abort_if_unique_id_configured() - return self.async_create_entry(title=user_input[CONF_NAME], data=user_input) + return self.async_create_entry(title=DEFAULT_NAME, data=user_input) finally: if avr is not None: avr.close() diff --git a/homeassistant/components/anthemav/media_player.py b/homeassistant/components/anthemav/media_player.py index 4056a34995a..91f8536d348 100644 --- a/homeassistant/components/anthemav/media_player.py +++ b/homeassistant/components/anthemav/media_player.py @@ -13,7 +13,7 @@ from homeassistant.components.media_player import ( MediaPlayerState, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_MAC, CONF_NAME +from homeassistant.const import CONF_MAC from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -30,7 +30,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up entry.""" - name = config_entry.data[CONF_NAME] + name = config_entry.title mac_address = config_entry.data[CONF_MAC] model = config_entry.data[CONF_MODEL] diff --git a/homeassistant/components/apcupsd/__init__.py b/homeassistant/components/apcupsd/__init__.py index 164a908e834..8d7c6b2f46d 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 d968784b5b9..0cade0f81ca 100644 --- a/homeassistant/components/api/__init__.py +++ b/homeassistant/components/api/__init__.py @@ -9,10 +9,12 @@ from aiohttp import web 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, @@ -195,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): @@ -214,14 +224,18 @@ 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): @@ -256,7 +270,7 @@ class APIEntityStateView(HomeAssistantView): # 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 8a2130faca0..6a85ea1d1a8 100644 --- a/homeassistant/components/apple_tv/config_flow.py +++ b/homeassistant/components/apple_tv/config_flow.py @@ -26,7 +26,6 @@ from homeassistant.helpers.schema_config_entry_flow import ( 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 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 04dcef05202..e67192040a6 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 011b8e67a19..1bac2bdfb5f 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/__init__.py b/homeassistant/components/assist_pipeline/__init__.py index 7f87bd254d0..9a61346f673 100644 --- a/homeassistant/components/assist_pipeline/__init__.py +++ b/homeassistant/components/assist_pipeline/__init__.py @@ -12,6 +12,7 @@ from homeassistant.helpers.typing import ConfigType from .const import DATA_CONFIG, DOMAIN from .error import PipelineNotFound from .pipeline import ( + AudioSettings, Pipeline, PipelineEvent, PipelineEventCallback, @@ -33,6 +34,7 @@ __all__ = ( "async_get_pipelines", "async_setup", "async_pipeline_from_audio_stream", + "AudioSettings", "Pipeline", "PipelineEvent", "PipelineEventType", @@ -71,6 +73,7 @@ async def async_pipeline_from_audio_stream( conversation_id: str | None = None, tts_audio_output: str | None = None, wake_word_settings: WakeWordSettings | None = None, + audio_settings: AudioSettings | None = None, device_id: str | None = None, start_stage: PipelineStage = PipelineStage.STT, end_stage: PipelineStage = PipelineStage.TTS, @@ -93,6 +96,7 @@ async def async_pipeline_from_audio_stream( event_callback=event_callback, tts_audio_output=tts_audio_output, wake_word_settings=wake_word_settings, + audio_settings=audio_settings or AudioSettings(), ), ) await pipeline_input.validate() diff --git a/homeassistant/components/assist_pipeline/error.py b/homeassistant/components/assist_pipeline/error.py index 094913424b6..209e2611ec0 100644 --- a/homeassistant/components/assist_pipeline/error.py +++ b/homeassistant/components/assist_pipeline/error.py @@ -22,6 +22,14 @@ class WakeWordDetectionError(PipelineError): """Error in wake-word-detection portion of pipeline.""" +class WakeWordDetectionAborted(WakeWordDetectionError): + """Wake-word-detection was aborted.""" + + def __init__(self) -> None: + """Set error message.""" + super().__init__("wake_word_detection_aborted", "") + + class WakeWordTimeoutError(WakeWordDetectionError): """Timeout when wake word was not detected.""" diff --git a/homeassistant/components/assist_pipeline/manifest.json b/homeassistant/components/assist_pipeline/manifest.json index 1db415b29d2..31b3b0d4e32 100644 --- a/homeassistant/components/assist_pipeline/manifest.json +++ b/homeassistant/components/assist_pipeline/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/assist_pipeline", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["webrtcvad==2.0.10"] + "requirements": ["webrtc-noise-gain==1.2.3"] } diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index f4d060ed7b8..76444fb2436 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -1,7 +1,9 @@ """Classes for voice assistant pipelines.""" from __future__ import annotations +import array import asyncio +from collections import defaultdict, deque from collections.abc import AsyncGenerator, AsyncIterable, Callable, Iterable from dataclasses import asdict, dataclass, field from enum import StrEnum @@ -10,10 +12,11 @@ from pathlib import Path from queue import Queue from threading import Thread import time -from typing import Any, cast +from typing import Any, Final, cast import wave import voluptuous as vol +from webrtc_noise_gain import AudioProcessor from homeassistant.components import ( conversation, @@ -29,6 +32,7 @@ from homeassistant.components.tts.media_source import ( from homeassistant.core import Context, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.collection import ( + CHANGE_UPDATED, CollectionError, ItemNotFound, SerializedStorageCollection, @@ -51,16 +55,17 @@ from .error import ( PipelineNotFound, SpeechToTextError, TextToSpeechError, + WakeWordDetectionAborted, WakeWordDetectionError, WakeWordTimeoutError, ) -from .ring_buffer import RingBuffer -from .vad import VoiceActivityTimeout, VoiceCommandSegmenter +from .vad import AudioBuffer, VoiceActivityTimeout, VoiceCommandSegmenter, chunk_samples _LOGGER = logging.getLogger(__name__) STORAGE_KEY = f"{DOMAIN}.pipelines" STORAGE_VERSION = 1 +STORAGE_VERSION_MINOR = 2 ENGINE_LANGUAGE_PAIRS = ( ("stt_engine", "stt_language"), @@ -86,12 +91,17 @@ PIPELINE_FIELDS = { vol.Required("tts_engine"): vol.Any(str, None), vol.Required("tts_language"): vol.Any(str, None), vol.Required("tts_voice"): vol.Any(str, None), + vol.Required("wake_word_entity"): vol.Any(str, None), + vol.Required("wake_word_id"): vol.Any(str, None), } STORED_PIPELINE_RUNS = 10 SAVE_DELAY = 10 +AUDIO_PROCESSOR_SAMPLES: Final = 160 # 10 ms @ 16 Khz +AUDIO_PROCESSOR_BYTES: Final = AUDIO_PROCESSOR_SAMPLES * 2 # 16-bit samples + async def _async_resolve_default_pipeline_settings( hass: HomeAssistant, @@ -111,6 +121,8 @@ async def _async_resolve_default_pipeline_settings( tts_engine = None tts_language = None tts_voice = None + wake_word_entity = None + wake_word_id = None # Find a matching language supported by the Home Assistant conversation agent conversation_languages = language_util.matches( @@ -188,6 +200,8 @@ async def _async_resolve_default_pipeline_settings( "tts_engine": tts_engine_id, "tts_language": tts_language, "tts_voice": tts_voice, + "wake_word_entity": wake_word_entity, + "wake_word_id": wake_word_id, } @@ -295,6 +309,8 @@ class Pipeline: tts_engine: str | None tts_language: str | None tts_voice: str | None + wake_word_entity: str | None + wake_word_id: str | None id: str = field(default_factory=ulid_util.ulid) @@ -316,6 +332,8 @@ class Pipeline: tts_engine=data["tts_engine"], tts_language=data["tts_language"], tts_voice=data["tts_voice"], + wake_word_entity=data["wake_word_entity"], + wake_word_id=data["wake_word_id"], ) def to_json(self) -> dict[str, Any]: @@ -331,6 +349,8 @@ class Pipeline: "tts_engine": self.tts_engine, "tts_language": self.tts_language, "tts_voice": self.tts_voice, + "wake_word_entity": self.wake_word_entity, + "wake_word_id": self.wake_word_id, } @@ -380,6 +400,60 @@ class WakeWordSettings: """Seconds of audio to buffer before detection and forward to STT.""" +@dataclass(frozen=True) +class AudioSettings: + """Settings for pipeline audio processing.""" + + noise_suppression_level: int = 0 + """Level of noise suppression (0 = disabled, 4 = max)""" + + auto_gain_dbfs: int = 0 + """Amount of automatic gain in dbFS (0 = disabled, 31 = max)""" + + volume_multiplier: float = 1.0 + """Multiplier used directly on PCM samples (1.0 = no change, 2.0 = twice as loud)""" + + is_vad_enabled: bool = True + """True if VAD is used to determine the end of the voice command.""" + + is_chunking_enabled: bool = True + """True if audio is automatically split into 10 ms chunks (required for VAD, etc.)""" + + def __post_init__(self) -> None: + """Verify settings post-initialization.""" + if (self.noise_suppression_level < 0) or (self.noise_suppression_level > 4): + raise ValueError("noise_suppression_level must be in [0, 4]") + + if (self.auto_gain_dbfs < 0) or (self.auto_gain_dbfs > 31): + raise ValueError("auto_gain_dbfs must be in [0, 31]") + + if self.needs_processor and (not self.is_chunking_enabled): + raise ValueError("Chunking must be enabled for audio processing") + + @property + def needs_processor(self) -> bool: + """True if an audio processor is needed.""" + return ( + self.is_vad_enabled + or (self.noise_suppression_level > 0) + or (self.auto_gain_dbfs > 0) + ) + + +@dataclass(frozen=True, slots=True) +class ProcessedAudioChunk: + """Processed audio chunk and metadata.""" + + audio: bytes + """Raw PCM audio @ 16Khz with 16-bit mono samples""" + + timestamp_ms: int + """Timestamp relative to start of audio stream (milliseconds)""" + + is_speech: bool | None + """True if audio chunk likely contains speech, False if not, None if unknown""" + + @dataclass class PipelineRun: """Running context for a pipeline.""" @@ -395,13 +469,16 @@ class PipelineRun: intent_agent: str | None = None tts_audio_output: str | None = None wake_word_settings: WakeWordSettings | None = None + audio_settings: AudioSettings = field(default_factory=AudioSettings) id: str = field(default_factory=ulid_util.ulid) - stt_provider: stt.SpeechToTextEntity | stt.Provider = field(init=False) - tts_engine: str = field(init=False) + stt_provider: stt.SpeechToTextEntity | stt.Provider = field(init=False, repr=False) + tts_engine: str = field(init=False, repr=False) tts_options: dict | None = field(init=False, default=None) - wake_word_engine: str = field(init=False) - wake_word_provider: wake_word.WakeWordDetectionEntity = field(init=False) + wake_word_entity_id: str | None = field(init=False, default=None, repr=False) + wake_word_entity: wake_word.WakeWordDetectionEntity = field(init=False, repr=False) + + abort_wake_word_detection: bool = field(init=False, default=False) debug_recording_thread: Thread | None = None """Thread that records audio to debug_recording_dir""" @@ -409,6 +486,12 @@ class PipelineRun: debug_recording_queue: Queue[str | bytes | None] | None = None """Queue to communicate with debug recording thread""" + audio_processor: AudioProcessor | None = None + """VAD/noise suppression/auto gain""" + + audio_processor_buffer: AudioBuffer = field(init=False, repr=False) + """Buffer used when splitting audio into chunks for audio processing""" + def __post_init__(self) -> None: """Set language for pipeline.""" self.language = self.pipeline.language or self.hass.config.language @@ -420,21 +503,37 @@ class PipelineRun: raise InvalidPipelineStagesError(self.start_stage, self.end_stage) pipeline_data: PipelineData = self.hass.data[DOMAIN] - if self.pipeline.id not in pipeline_data.pipeline_runs: - pipeline_data.pipeline_runs[self.pipeline.id] = LimitedSizeDict( + if self.pipeline.id not in pipeline_data.pipeline_debug: + pipeline_data.pipeline_debug[self.pipeline.id] = LimitedSizeDict( size_limit=STORED_PIPELINE_RUNS ) - pipeline_data.pipeline_runs[self.pipeline.id][self.id] = PipelineRunDebug() + pipeline_data.pipeline_debug[self.pipeline.id][self.id] = PipelineRunDebug() + pipeline_data.pipeline_runs.add_run(self) + + # Initialize with audio settings + self.audio_processor_buffer = AudioBuffer(AUDIO_PROCESSOR_BYTES) + if self.audio_settings.needs_processor: + self.audio_processor = AudioProcessor( + self.audio_settings.auto_gain_dbfs, + self.audio_settings.noise_suppression_level, + ) + + def __eq__(self, other: Any) -> bool: + """Compare pipeline runs by id.""" + if isinstance(other, PipelineRun): + return self.id == other.id + + return False @callback def process_event(self, event: PipelineEvent) -> None: """Log an event and call listener.""" self.event_callback(event) pipeline_data: PipelineData = self.hass.data[DOMAIN] - if self.id not in pipeline_data.pipeline_runs[self.pipeline.id]: + if self.id not in pipeline_data.pipeline_debug[self.pipeline.id]: # This run has been evicted from the logged pipeline runs already return - pipeline_data.pipeline_runs[self.pipeline.id][self.id].events.append(event) + pipeline_data.pipeline_debug[self.pipeline.id][self.id].events.append(event) def start(self, device_id: str | None) -> None: """Emit run start event.""" @@ -461,31 +560,36 @@ class PipelineRun: ) ) + pipeline_data: PipelineData = self.hass.data[DOMAIN] + pipeline_data.pipeline_runs.remove_run(self) + async def prepare_wake_word_detection(self) -> None: """Prepare wake-word-detection.""" - engine = wake_word.async_default_engine(self.hass) - if engine is None: + entity_id = self.pipeline.wake_word_entity or wake_word.async_default_entity( + self.hass + ) + if entity_id is None: raise WakeWordDetectionError( code="wake-engine-missing", message="No wake word engine", ) - wake_word_provider = wake_word.async_get_wake_word_detection_entity( - self.hass, engine + wake_word_entity = wake_word.async_get_wake_word_detection_entity( + self.hass, entity_id ) - if wake_word_provider is None: + if wake_word_entity is None: raise WakeWordDetectionError( code="wake-provider-missing", - message=f"No wake-word-detection provider for: {engine}", + message=f"No wake-word-detection provider for: {entity_id}", ) - self.wake_word_engine = engine - self.wake_word_provider = wake_word_provider + self.wake_word_entity_id = entity_id + self.wake_word_entity = wake_word_entity async def wake_word_detection( self, - stream: AsyncIterable[bytes], - audio_chunks_for_stt: list[bytes], + stream: AsyncIterable[ProcessedAudioChunk], + audio_chunks_for_stt: list[ProcessedAudioChunk], ) -> wake_word.DetectionResult | None: """Run wake-word-detection portion of pipeline. Returns detection result.""" metadata_dict = asdict( @@ -506,14 +610,14 @@ class PipelineRun: PipelineEvent( PipelineEventType.WAKE_WORD_START, { - "engine": self.wake_word_engine, + "entity_id": self.wake_word_entity_id, "metadata": metadata_dict, }, ) ) if self.debug_recording_queue is not None: - self.debug_recording_queue.put_nowait(f"00_wake-{self.wake_word_engine}") + self.debug_recording_queue.put_nowait(f"00_wake-{self.wake_word_entity_id}") wake_word_settings = self.wake_word_settings or WakeWordSettings() @@ -526,27 +630,31 @@ class PipelineRun: # Audio chunk buffer. This audio will be forwarded to speech-to-text # after wake-word-detection. - num_audio_bytes_to_buffer = int( - wake_word_settings.audio_seconds_to_buffer * 16000 * 2 # 16-bit @ 16Khz + num_audio_chunks_to_buffer = int( + (wake_word_settings.audio_seconds_to_buffer * 16000) + / AUDIO_PROCESSOR_SAMPLES ) - stt_audio_buffer: RingBuffer | None = None - if num_audio_bytes_to_buffer > 0: - stt_audio_buffer = RingBuffer(num_audio_bytes_to_buffer) + stt_audio_buffer: deque[ProcessedAudioChunk] | None = None + if num_audio_chunks_to_buffer > 0: + stt_audio_buffer = deque(maxlen=num_audio_chunks_to_buffer) try: # Detect wake word(s) - result = await self.wake_word_provider.async_process_audio_stream( + result = await self.wake_word_entity.async_process_audio_stream( self._wake_word_audio_stream( audio_stream=stream, stt_audio_buffer=stt_audio_buffer, wake_word_vad=wake_word_vad, - ) + ), + self.pipeline.wake_word_id, ) if stt_audio_buffer is not None: # All audio kept from right before the wake word was detected as # a single chunk. - audio_chunks_for_stt.append(stt_audio_buffer.getvalue()) + audio_chunks_for_stt.extend(stt_audio_buffer) + except WakeWordDetectionAborted: + raise except WakeWordTimeoutError: _LOGGER.debug("Timeout during wake word detection") raise @@ -570,7 +678,11 @@ class PipelineRun: # speech-to-text so the user does not have to pause before # speaking the voice command. for chunk_ts in result.queued_audio: - audio_chunks_for_stt.append(chunk_ts[0]) + audio_chunks_for_stt.append( + ProcessedAudioChunk( + audio=chunk_ts[0], timestamp_ms=chunk_ts[1], is_speech=False + ) + ) wake_word_output = asdict(result) @@ -588,8 +700,8 @@ class PipelineRun: async def _wake_word_audio_stream( self, - audio_stream: AsyncIterable[bytes], - stt_audio_buffer: RingBuffer | None, + audio_stream: AsyncIterable[ProcessedAudioChunk], + stt_audio_buffer: deque[ProcessedAudioChunk] | None, wake_word_vad: VoiceActivityTimeout | None, sample_rate: int = 16000, sample_width: int = 2, @@ -599,25 +711,27 @@ class PipelineRun: Adds audio to a ring buffer that will be forwarded to speech-to-text after detection. Times out if VAD detects enough silence. """ - ms_per_sample = sample_rate // 1000 - timestamp_ms = 0 + chunk_seconds = AUDIO_PROCESSOR_SAMPLES / sample_rate async for chunk in audio_stream: - if self.debug_recording_queue is not None: - self.debug_recording_queue.put_nowait(chunk) + if self.abort_wake_word_detection: + raise WakeWordDetectionAborted - yield chunk, timestamp_ms - timestamp_ms += (len(chunk) // sample_width) // ms_per_sample + if self.debug_recording_queue is not None: + self.debug_recording_queue.put_nowait(chunk.audio) + + yield chunk.audio, chunk.timestamp_ms # Wake-word-detection occurs *after* the wake word was actually # spoken. Keeping audio right before detection allows the voice # command to be spoken immediately after the wake word. if stt_audio_buffer is not None: - stt_audio_buffer.put(chunk) + stt_audio_buffer.append(chunk) - if (wake_word_vad is not None) and (not wake_word_vad.process(chunk)): - raise WakeWordTimeoutError( - code="wake-word-timeout", message="Wake word was not detected" - ) + if wake_word_vad is not None: + if not wake_word_vad.process(chunk_seconds, chunk.is_speech): + raise WakeWordTimeoutError( + code="wake-word-timeout", message="Wake word was not detected" + ) async def prepare_speech_to_text(self, metadata: stt.SpeechMetadata) -> None: """Prepare speech-to-text.""" @@ -650,7 +764,7 @@ class PipelineRun: async def speech_to_text( self, metadata: stt.SpeechMetadata, - stream: AsyncIterable[bytes], + stream: AsyncIterable[ProcessedAudioChunk], ) -> str: """Run speech-to-text portion of pipeline. Returns the spoken text.""" if isinstance(self.stt_provider, stt.Provider): @@ -674,11 +788,13 @@ class PipelineRun: try: # Transcribe audio stream + stt_vad: VoiceCommandSegmenter | None = None + if self.audio_settings.is_vad_enabled: + stt_vad = VoiceCommandSegmenter() + result = await self.stt_provider.async_process_audio_stream( metadata, - self._speech_to_text_stream( - audio_stream=stream, stt_vad=VoiceCommandSegmenter() - ), + self._speech_to_text_stream(audio_stream=stream, stt_vad=stt_vad), ) except Exception as src_error: _LOGGER.exception("Unexpected error during speech-to-text") @@ -715,26 +831,25 @@ class PipelineRun: async def _speech_to_text_stream( self, - audio_stream: AsyncIterable[bytes], + audio_stream: AsyncIterable[ProcessedAudioChunk], stt_vad: VoiceCommandSegmenter | None, sample_rate: int = 16000, sample_width: int = 2, ) -> AsyncGenerator[bytes, None]: """Yield audio chunks until VAD detects silence or speech-to-text completes.""" - ms_per_sample = sample_rate // 1000 + chunk_seconds = AUDIO_PROCESSOR_SAMPLES / sample_rate sent_vad_start = False - timestamp_ms = 0 async for chunk in audio_stream: if self.debug_recording_queue is not None: - self.debug_recording_queue.put_nowait(chunk) + self.debug_recording_queue.put_nowait(chunk.audio) if stt_vad is not None: - if not stt_vad.process(chunk): + if not stt_vad.process(chunk_seconds, chunk.is_speech): # Silence detected at the end of voice command self.process_event( PipelineEvent( PipelineEventType.STT_VAD_END, - {"timestamp": timestamp_ms}, + {"timestamp": chunk.timestamp_ms}, ) ) break @@ -744,13 +859,12 @@ class PipelineRun: self.process_event( PipelineEvent( PipelineEventType.STT_VAD_START, - {"timestamp": timestamp_ms}, + {"timestamp": chunk.timestamp_ms}, ) ) sent_vad_start = True - yield chunk - timestamp_ms += (len(chunk) // sample_width) // ms_per_sample + yield chunk.audio async def prepare_recognize_intent(self) -> None: """Prepare recognizing an intent.""" @@ -961,6 +1075,87 @@ class PipelineRun: self.debug_recording_queue = None self.debug_recording_thread = None + async def process_volume_only( + self, + audio_stream: AsyncIterable[bytes], + sample_rate: int = 16000, + sample_width: int = 2, + ) -> AsyncGenerator[ProcessedAudioChunk, None]: + """Apply volume transformation only (no VAD/audio enhancements) with optional chunking.""" + ms_per_sample = sample_rate // 1000 + ms_per_chunk = (AUDIO_PROCESSOR_SAMPLES // sample_width) // ms_per_sample + timestamp_ms = 0 + + async for chunk in audio_stream: + if self.audio_settings.volume_multiplier != 1.0: + chunk = _multiply_volume(chunk, self.audio_settings.volume_multiplier) + + if self.audio_settings.is_chunking_enabled: + # 10 ms chunking + for chunk_10ms in chunk_samples( + chunk, AUDIO_PROCESSOR_BYTES, self.audio_processor_buffer + ): + yield ProcessedAudioChunk( + audio=chunk_10ms, + timestamp_ms=timestamp_ms, + is_speech=None, # no VAD + ) + timestamp_ms += ms_per_chunk + else: + # No chunking + yield ProcessedAudioChunk( + audio=chunk, + timestamp_ms=timestamp_ms, + is_speech=None, # no VAD + ) + timestamp_ms += (len(chunk) // sample_width) // ms_per_sample + + async def process_enhance_audio( + self, + audio_stream: AsyncIterable[bytes], + sample_rate: int = 16000, + sample_width: int = 2, + ) -> AsyncGenerator[ProcessedAudioChunk, None]: + """Split audio into 10 ms chunks and apply VAD/noise suppression/auto gain/volume transformation.""" + assert self.audio_processor is not None + + ms_per_sample = sample_rate // 1000 + ms_per_chunk = (AUDIO_PROCESSOR_SAMPLES // sample_width) // ms_per_sample + timestamp_ms = 0 + + async for dirty_samples in audio_stream: + if self.audio_settings.volume_multiplier != 1.0: + # Static gain + dirty_samples = _multiply_volume( + dirty_samples, self.audio_settings.volume_multiplier + ) + + # Split into 10ms chunks for audio enhancements/VAD + for dirty_10ms_chunk in chunk_samples( + dirty_samples, AUDIO_PROCESSOR_BYTES, self.audio_processor_buffer + ): + ap_result = self.audio_processor.Process10ms(dirty_10ms_chunk) + yield ProcessedAudioChunk( + audio=ap_result.audio, + timestamp_ms=timestamp_ms, + is_speech=ap_result.is_speech, + ) + + timestamp_ms += ms_per_chunk + + +def _multiply_volume(chunk: bytes, volume_multiplier: float) -> bytes: + """Multiplies 16-bit PCM samples by a constant.""" + + def _clamp(val: float) -> float: + """Clamp to signed 16-bit.""" + return max(-32768, min(32767, val)) + + return array.array( + "h", + (int(_clamp(value * volume_multiplier)) for value in array.array("h", chunk)), + ).tobytes() + def _pipeline_debug_recording_thread_proc( run_recording_dir: Path, @@ -1026,18 +1221,26 @@ class PipelineInput: """Run pipeline.""" self.run.start(device_id=self.device_id) current_stage: PipelineStage | None = self.run.start_stage - stt_audio_buffer: list[bytes] = [] + stt_audio_buffer: list[ProcessedAudioChunk] = [] + stt_processed_stream: AsyncIterable[ProcessedAudioChunk] | None = None + + if self.stt_stream is not None: + if self.run.audio_settings.needs_processor: + # VAD/noise suppression/auto gain/volume + stt_processed_stream = self.run.process_enhance_audio(self.stt_stream) + else: + # Volume multiplier only + stt_processed_stream = self.run.process_volume_only(self.stt_stream) try: if current_stage == PipelineStage.WAKE_WORD: # wake-word-detection - assert self.stt_stream is not None + assert stt_processed_stream is not None detect_result = await self.run.wake_word_detection( - self.stt_stream, stt_audio_buffer + stt_processed_stream, stt_audio_buffer ) if detect_result is None: # No wake word. Abort the rest of the pipeline. - await self.run.end() return current_stage = PipelineStage.STT @@ -1046,28 +1249,30 @@ class PipelineInput: intent_input = self.intent_input if current_stage == PipelineStage.STT: assert self.stt_metadata is not None - assert self.stt_stream is not None + assert stt_processed_stream is not None - stt_stream = self.stt_stream + stt_input_stream = stt_processed_stream if stt_audio_buffer: # Send audio in the buffer first to speech-to-text, then move on to stt_stream. # This is basically an async itertools.chain. - async def buffer_then_audio_stream() -> AsyncGenerator[bytes, None]: + async def buffer_then_audio_stream() -> AsyncGenerator[ + ProcessedAudioChunk, None + ]: # Buffered audio for chunk in stt_audio_buffer: yield chunk # Streamed audio - assert self.stt_stream is not None - async for chunk in self.stt_stream: + assert stt_processed_stream is not None + async for chunk in stt_processed_stream: yield chunk - stt_stream = buffer_then_audio_stream() + stt_input_stream = buffer_then_audio_stream() intent_input = await self.run.speech_to_text( self.stt_metadata, - stt_stream, + stt_input_stream, ) current_stage = PipelineStage.INTENT @@ -1362,13 +1567,46 @@ class PipelineStorageCollectionWebsocket( connection.send_result(msg["id"]) -@dataclass +class PipelineRuns: + """Class managing pipelineruns.""" + + def __init__(self, pipeline_store: PipelineStorageCollection) -> None: + """Initialize.""" + self._pipeline_runs: dict[str, dict[str, PipelineRun]] = defaultdict(dict) + self._pipeline_store = pipeline_store + pipeline_store.async_add_listener(self._change_listener) + + def add_run(self, pipeline_run: PipelineRun) -> None: + """Add pipeline run.""" + pipeline_id = pipeline_run.pipeline.id + self._pipeline_runs[pipeline_id][pipeline_run.id] = pipeline_run + + def remove_run(self, pipeline_run: PipelineRun) -> None: + """Remove pipeline run.""" + pipeline_id = pipeline_run.pipeline.id + self._pipeline_runs[pipeline_id].pop(pipeline_run.id) + + async def _change_listener( + self, change_type: str, item_id: str, change: dict + ) -> None: + """Handle pipeline store changes.""" + if change_type != CHANGE_UPDATED: + return + if pipeline_runs := self._pipeline_runs.get(item_id): + # Create a temporary list in case the list is modified while we iterate + for pipeline_run in list(pipeline_runs.values()): + pipeline_run.abort_wake_word_detection = True + + class PipelineData: """Store and debug data stored in hass.data.""" - pipeline_runs: dict[str, LimitedSizeDict[str, PipelineRunDebug]] - pipeline_store: PipelineStorageCollection - pipeline_devices: set[str] = field(default_factory=set, init=False) + def __init__(self, pipeline_store: PipelineStorageCollection) -> None: + """Initialize.""" + self.pipeline_store = pipeline_store + self.pipeline_debug: dict[str, LimitedSizeDict[str, PipelineRunDebug]] = {} + self.pipeline_devices: set[str] = set() + self.pipeline_runs = PipelineRuns(pipeline_store) @dataclass @@ -1382,11 +1620,35 @@ class PipelineRunDebug: ) +class PipelineStore(Store[SerializedPipelineStorageCollection]): + """Store entity registry data.""" + + async def _async_migrate_func( + self, + old_major_version: int, + old_minor_version: int, + old_data: SerializedPipelineStorageCollection, + ) -> SerializedPipelineStorageCollection: + """Migrate to the new version.""" + if old_major_version == 1 and old_minor_version < 2: + # Version 1.2 adds wake word configuration + for pipeline in old_data["items"]: + # Populate keys which were introduced before version 1.2 + pipeline.setdefault("wake_word_entity", None) + pipeline.setdefault("wake_word_id", None) + + if old_major_version > 1: + raise NotImplementedError + return old_data + + @singleton(DOMAIN) async def async_setup_pipeline_store(hass: HomeAssistant) -> PipelineData: """Set up the pipeline storage collection.""" pipeline_store = PipelineStorageCollection( - Store(hass, STORAGE_VERSION, STORAGE_KEY) + PipelineStore( + hass, STORAGE_VERSION, STORAGE_KEY, minor_version=STORAGE_VERSION_MINOR + ) ) await pipeline_store.async_load() PipelineStorageCollectionWebsocket( @@ -1396,4 +1658,4 @@ async def async_setup_pipeline_store(hass: HomeAssistant) -> PipelineData: PIPELINE_FIELDS, PIPELINE_FIELDS, ).async_setup(hass) - return PipelineData({}, pipeline_store) + return PipelineData(pipeline_store) diff --git a/homeassistant/components/assist_pipeline/vad.py b/homeassistant/components/assist_pipeline/vad.py index 20a048d5621..30fad1c80d6 100644 --- a/homeassistant/components/assist_pipeline/vad.py +++ b/homeassistant/components/assist_pipeline/vad.py @@ -1,12 +1,13 @@ """Voice activity detection.""" from __future__ import annotations +from abc import ABC, abstractmethod from collections.abc import Iterable -from dataclasses import dataclass, field +from dataclasses import dataclass from enum import StrEnum -from typing import Final +from typing import Final, cast -import webrtcvad +from webrtc_noise_gain import AudioProcessor _SAMPLE_RATE: Final = 16000 # Hz _SAMPLE_WIDTH: Final = 2 # bytes @@ -32,6 +33,38 @@ class VadSensitivity(StrEnum): return 1.0 +class VoiceActivityDetector(ABC): + """Base class for voice activity detectors (VAD).""" + + @abstractmethod + def is_speech(self, chunk: bytes) -> bool: + """Return True if audio chunk contains speech.""" + + @property + @abstractmethod + def samples_per_chunk(self) -> int | None: + """Return number of samples per chunk or None if chunking is not required.""" + + +class WebRtcVad(VoiceActivityDetector): + """Voice activity detector based on webrtc.""" + + def __init__(self) -> None: + """Initialize webrtcvad.""" + # Just VAD: no noise suppression or auto gain + self._audio_processor = AudioProcessor(0, 0) + + def is_speech(self, chunk: bytes) -> bool: + """Return True if audio chunk contains speech.""" + result = self._audio_processor.Process10ms(chunk) + return cast(bool, result.is_speech) + + @property + def samples_per_chunk(self) -> int | None: + """Return 10 ms.""" + return int(0.01 * _SAMPLE_RATE) # 10 ms + + class AudioBuffer: """Fixed-sized audio buffer with variable internal length.""" @@ -73,13 +106,7 @@ class AudioBuffer: @dataclass class VoiceCommandSegmenter: - """Segments an audio stream into voice commands using webrtcvad.""" - - vad_mode: int = 3 - """Aggressiveness in filtering out non-speech. 3 is the most aggressive.""" - - vad_samples_per_chunk: int = 480 # 30 ms - """Must be 10, 20, or 30 ms at 16Khz.""" + """Segments an audio stream into voice commands.""" speech_seconds: float = 0.3 """Seconds of speech before voice command has started.""" @@ -108,85 +135,85 @@ class VoiceCommandSegmenter: _reset_seconds_left: float = 0.0 """Seconds left before resetting start/stop time counters.""" - _vad: webrtcvad.Vad = None - _leftover_chunk_buffer: AudioBuffer = field(init=False) - _bytes_per_chunk: int = field(init=False) - _seconds_per_chunk: float = field(init=False) - def __post_init__(self) -> None: - """Initialize VAD.""" - self._vad = webrtcvad.Vad(self.vad_mode) - self._bytes_per_chunk = self.vad_samples_per_chunk * _SAMPLE_WIDTH - self._seconds_per_chunk = self.vad_samples_per_chunk / _SAMPLE_RATE - self._leftover_chunk_buffer = AudioBuffer( - self.vad_samples_per_chunk * _SAMPLE_WIDTH - ) + """Reset after initialization.""" self.reset() def reset(self) -> None: """Reset all counters and state.""" - self._leftover_chunk_buffer.clear() self._speech_seconds_left = self.speech_seconds self._silence_seconds_left = self.silence_seconds self._timeout_seconds_left = self.timeout_seconds self._reset_seconds_left = self.reset_seconds self.in_command = False - def process(self, samples: bytes) -> bool: - """Process 16-bit 16Khz mono audio samples. + def process(self, chunk_seconds: float, is_speech: bool | None) -> bool: + """Process samples using external VAD. Returns False when command is done. """ - for chunk in chunk_samples( - samples, self._bytes_per_chunk, self._leftover_chunk_buffer - ): - if not self._process_chunk(chunk): - self.reset() - return False - - return True - - @property - def audio_buffer(self) -> bytes: - """Get partial chunk in the audio buffer.""" - return self._leftover_chunk_buffer.bytes() - - def _process_chunk(self, chunk: bytes) -> bool: - """Process a single chunk of 16-bit 16Khz mono audio. - - Returns False when command is done. - """ - is_speech = self._vad.is_speech(chunk, _SAMPLE_RATE) - - self._timeout_seconds_left -= self._seconds_per_chunk + self._timeout_seconds_left -= chunk_seconds if self._timeout_seconds_left <= 0: + self.reset() return False if not self.in_command: if is_speech: self._reset_seconds_left = self.reset_seconds - self._speech_seconds_left -= self._seconds_per_chunk + self._speech_seconds_left -= chunk_seconds if self._speech_seconds_left <= 0: # Inside voice command self.in_command = True else: # Reset if enough silence - self._reset_seconds_left -= self._seconds_per_chunk + self._reset_seconds_left -= chunk_seconds if self._reset_seconds_left <= 0: self._speech_seconds_left = self.speech_seconds elif not is_speech: self._reset_seconds_left = self.reset_seconds - self._silence_seconds_left -= self._seconds_per_chunk + self._silence_seconds_left -= chunk_seconds if self._silence_seconds_left <= 0: + self.reset() return False else: # Reset if enough speech - self._reset_seconds_left -= self._seconds_per_chunk + self._reset_seconds_left -= chunk_seconds if self._reset_seconds_left <= 0: self._silence_seconds_left = self.silence_seconds return True + def process_with_vad( + self, + chunk: bytes, + vad: VoiceActivityDetector, + leftover_chunk_buffer: AudioBuffer | None, + ) -> bool: + """Process an audio chunk using an external VAD. + + A buffer is required if the VAD requires fixed-sized audio chunks (usually the case). + + Returns False when voice command is finished. + """ + if vad.samples_per_chunk is None: + # No chunking + chunk_seconds = (len(chunk) // _SAMPLE_WIDTH) / _SAMPLE_RATE + is_speech = vad.is_speech(chunk) + return self.process(chunk_seconds, is_speech) + + if leftover_chunk_buffer is None: + raise ValueError("leftover_chunk_buffer is required when vad uses chunking") + + # With chunking + seconds_per_chunk = vad.samples_per_chunk / _SAMPLE_RATE + bytes_per_chunk = vad.samples_per_chunk * _SAMPLE_WIDTH + for vad_chunk in chunk_samples(chunk, bytes_per_chunk, leftover_chunk_buffer): + is_speech = vad.is_speech(vad_chunk) + if not self.process(seconds_per_chunk, is_speech): + return False + + return True + @dataclass class VoiceActivityTimeout: @@ -198,73 +225,43 @@ class VoiceActivityTimeout: reset_seconds: float = 0.5 """Seconds of speech before resetting timeout.""" - vad_mode: int = 3 - """Aggressiveness in filtering out non-speech. 3 is the most aggressive.""" - - vad_samples_per_chunk: int = 480 # 30 ms - """Must be 10, 20, or 30 ms at 16Khz.""" - _silence_seconds_left: float = 0.0 """Seconds left before considering voice command as stopped.""" _reset_seconds_left: float = 0.0 """Seconds left before resetting start/stop time counters.""" - _vad: webrtcvad.Vad = None - _leftover_chunk_buffer: AudioBuffer = field(init=False) - _bytes_per_chunk: int = field(init=False) - _seconds_per_chunk: float = field(init=False) - def __post_init__(self) -> None: - """Initialize VAD.""" - self._vad = webrtcvad.Vad(self.vad_mode) - self._bytes_per_chunk = self.vad_samples_per_chunk * _SAMPLE_WIDTH - self._seconds_per_chunk = self.vad_samples_per_chunk / _SAMPLE_RATE - self._leftover_chunk_buffer = AudioBuffer( - self.vad_samples_per_chunk * _SAMPLE_WIDTH - ) + """Reset after initialization.""" self.reset() def reset(self) -> None: """Reset all counters and state.""" - self._leftover_chunk_buffer.clear() self._silence_seconds_left = self.silence_seconds self._reset_seconds_left = self.reset_seconds - def process(self, samples: bytes) -> bool: - """Process 16-bit 16Khz mono audio samples. + def process(self, chunk_seconds: float, is_speech: bool | None) -> bool: + """Process samples using external VAD. Returns False when timeout is reached. """ - for chunk in chunk_samples( - samples, self._bytes_per_chunk, self._leftover_chunk_buffer - ): - if not self._process_chunk(chunk): - return False - - return True - - def _process_chunk(self, chunk: bytes) -> bool: - """Process a single chunk of 16-bit 16Khz mono audio. - - Returns False when timeout is reached. - """ - if self._vad.is_speech(chunk, _SAMPLE_RATE): + if is_speech: # Speech - self._reset_seconds_left -= self._seconds_per_chunk + self._reset_seconds_left -= chunk_seconds if self._reset_seconds_left <= 0: # Reset timeout self._silence_seconds_left = self.silence_seconds else: # Silence - self._silence_seconds_left -= self._seconds_per_chunk + self._silence_seconds_left -= chunk_seconds if self._silence_seconds_left <= 0: # Timeout reached + self.reset() return False # Slowly build reset counter back up self._reset_seconds_left = min( - self.reset_seconds, self._reset_seconds_left + self._seconds_per_chunk + self.reset_seconds, self._reset_seconds_left + chunk_seconds ) return True diff --git a/homeassistant/components/assist_pipeline/websocket_api.py b/homeassistant/components/assist_pipeline/websocket_api.py index 57e2cc8b398..798843ea6e3 100644 --- a/homeassistant/components/assist_pipeline/websocket_api.py +++ b/homeassistant/components/assist_pipeline/websocket_api.py @@ -18,6 +18,7 @@ from homeassistant.util import language as language_util from .const import DOMAIN from .error import PipelineNotFound from .pipeline import ( + AudioSettings, PipelineData, PipelineError, PipelineEvent, @@ -29,8 +30,8 @@ from .pipeline import ( async_get_pipeline, ) -DEFAULT_TIMEOUT = 30 -DEFAULT_WAKE_WORD_TIMEOUT = 3 +DEFAULT_TIMEOUT = 60 * 5 # seconds +DEFAULT_WAKE_WORD_TIMEOUT = 3 # seconds _LOGGER = logging.getLogger(__name__) @@ -71,6 +72,13 @@ def async_register_websocket_api(hass: HomeAssistant) -> None: vol.Optional("audio_seconds_to_buffer"): vol.Any( float, int ), + # Audio enhancement + vol.Optional("noise_suppression_level"): int, + vol.Optional("auto_gain_dbfs"): int, + vol.Optional("volume_multiplier"): float, + # Advanced use cases/testing + vol.Optional("no_vad"): bool, + vol.Optional("no_chunking"): bool, } }, extra=vol.ALLOW_EXTRA, @@ -115,6 +123,7 @@ async def websocket_run( handler_id: int | None = None unregister_handler: Callable[[], None] | None = None wake_word_settings: WakeWordSettings | None = None + audio_settings: AudioSettings | None = None # Arguments to PipelineInput input_args: dict[str, Any] = { @@ -124,13 +133,14 @@ async def websocket_run( if start_stage in (PipelineStage.WAKE_WORD, PipelineStage.STT): # Audio pipeline that will receive audio as binary websocket messages + msg_input = msg["input"] audio_queue: asyncio.Queue[bytes] = asyncio.Queue() - incoming_sample_rate = msg["input"]["sample_rate"] + incoming_sample_rate = msg_input["sample_rate"] if start_stage == PipelineStage.WAKE_WORD: wake_word_settings = WakeWordSettings( timeout=msg["input"].get("timeout", DEFAULT_WAKE_WORD_TIMEOUT), - audio_seconds_to_buffer=msg["input"].get("audio_seconds_to_buffer", 0), + audio_seconds_to_buffer=msg_input.get("audio_seconds_to_buffer", 0), ) async def stt_stream() -> AsyncGenerator[bytes, None]: @@ -166,6 +176,15 @@ async def websocket_run( channel=stt.AudioChannels.CHANNEL_MONO, ) input_args["stt_stream"] = stt_stream() + + # Audio settings + audio_settings = AudioSettings( + noise_suppression_level=msg_input.get("noise_suppression_level", 0), + auto_gain_dbfs=msg_input.get("auto_gain_dbfs", 0), + volume_multiplier=msg_input.get("volume_multiplier", 1.0), + is_vad_enabled=not msg_input.get("no_vad", False), + is_chunking_enabled=not msg_input.get("no_chunking", False), + ) elif start_stage == PipelineStage.INTENT: # Input to conversation agent input_args["intent_input"] = msg["input"]["text"] @@ -185,6 +204,7 @@ async def websocket_run( "timeout": timeout, }, wake_word_settings=wake_word_settings, + audio_settings=audio_settings or AudioSettings(), ) pipeline_input = PipelineInput(**input_args) @@ -238,18 +258,18 @@ def websocket_list_runs( pipeline_data: PipelineData = hass.data[DOMAIN] pipeline_id = msg["pipeline_id"] - if pipeline_id not in pipeline_data.pipeline_runs: + if pipeline_id not in pipeline_data.pipeline_debug: connection.send_result(msg["id"], {"pipeline_runs": []}) return - pipeline_runs = pipeline_data.pipeline_runs[pipeline_id] + pipeline_debug = pipeline_data.pipeline_debug[pipeline_id] connection.send_result( msg["id"], { "pipeline_runs": [ {"pipeline_run_id": id, "timestamp": pipeline_run.timestamp} - for id, pipeline_run in pipeline_runs.items() + for id, pipeline_run in pipeline_debug.items() ] }, ) @@ -274,7 +294,7 @@ def websocket_get_run( pipeline_id = msg["pipeline_id"] pipeline_run_id = msg["pipeline_run_id"] - if pipeline_id not in pipeline_data.pipeline_runs: + if pipeline_id not in pipeline_data.pipeline_debug: connection.send_error( msg["id"], websocket_api.const.ERR_NOT_FOUND, @@ -282,9 +302,9 @@ def websocket_get_run( ) return - pipeline_runs = pipeline_data.pipeline_runs[pipeline_id] + pipeline_debug = pipeline_data.pipeline_debug[pipeline_id] - if pipeline_run_id not in pipeline_runs: + if pipeline_run_id not in pipeline_debug: connection.send_error( msg["id"], websocket_api.const.ERR_NOT_FOUND, @@ -294,7 +314,7 @@ def websocket_get_run( connection.send_result( msg["id"], - {"events": pipeline_runs[pipeline_run_id].events}, + {"events": pipeline_debug[pipeline_run_id].events}, ) @@ -332,7 +352,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 +362,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 840c48aff2a..8348e40ba6b 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/const.py b/homeassistant/components/august/const.py index 752499e29e2..b97890d09b6 100644 --- a/homeassistant/components/august/const.py +++ b/homeassistant/components/august/const.py @@ -26,12 +26,16 @@ DOMAIN = "august" OPERATION_METHOD_AUTORELOCK = "autorelock" OPERATION_METHOD_REMOTE = "remote" OPERATION_METHOD_KEYPAD = "keypad" +OPERATION_METHOD_MANUAL = "manual" +OPERATION_METHOD_TAG = "tag" OPERATION_METHOD_MOBILE_DEVICE = "mobile" ATTR_OPERATION_AUTORELOCK = "autorelock" ATTR_OPERATION_METHOD = "method" ATTR_OPERATION_REMOTE = "remote" ATTR_OPERATION_KEYPAD = "keypad" +ATTR_OPERATION_MANUAL = "manual" +ATTR_OPERATION_TAG = "tag" # Limit battery, online, and hardware updates to hourly # in order to reduce the number of api requests and diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index c5a0da71136..2fe7d62ac3d 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.9.0", "yalexs-ble==2.3.0"] + "requirements": ["yalexs==1.10.0", "yalexs-ble==2.3.0"] } diff --git a/homeassistant/components/august/sensor.py b/homeassistant/components/august/sensor.py index 12ed3a88558..75e8cd8984c 100644 --- a/homeassistant/components/august/sensor.py +++ b/homeassistant/components/august/sensor.py @@ -33,13 +33,17 @@ from . import AugustData from .const import ( ATTR_OPERATION_AUTORELOCK, ATTR_OPERATION_KEYPAD, + ATTR_OPERATION_MANUAL, ATTR_OPERATION_METHOD, ATTR_OPERATION_REMOTE, + ATTR_OPERATION_TAG, DOMAIN, OPERATION_METHOD_AUTORELOCK, OPERATION_METHOD_KEYPAD, + OPERATION_METHOD_MANUAL, OPERATION_METHOD_MOBILE_DEVICE, OPERATION_METHOD_REMOTE, + OPERATION_METHOD_TAG, ) from .entity import AugustEntityMixin @@ -183,6 +187,8 @@ class AugustOperatorSensor(AugustEntityMixin, RestoreEntity, SensorEntity): self._device = device self._operated_remote = None self._operated_keypad = None + self._operated_manual = None + self._operated_tag = None self._operated_autorelock = None self._operated_time = None self._attr_unique_id = f"{self._device_id}_lock_operator" @@ -200,6 +206,8 @@ class AugustOperatorSensor(AugustEntityMixin, RestoreEntity, SensorEntity): self._attr_native_value = lock_activity.operated_by self._operated_remote = lock_activity.operated_remote self._operated_keypad = lock_activity.operated_keypad + self._operated_manual = lock_activity.operated_manual + self._operated_tag = lock_activity.operated_tag self._operated_autorelock = lock_activity.operated_autorelock self._attr_entity_picture = lock_activity.operator_thumbnail_url @@ -212,6 +220,10 @@ class AugustOperatorSensor(AugustEntityMixin, RestoreEntity, SensorEntity): attributes[ATTR_OPERATION_REMOTE] = self._operated_remote if self._operated_keypad is not None: attributes[ATTR_OPERATION_KEYPAD] = self._operated_keypad + if self._operated_manual is not None: + attributes[ATTR_OPERATION_MANUAL] = self._operated_manual + if self._operated_tag is not None: + attributes[ATTR_OPERATION_TAG] = self._operated_tag if self._operated_autorelock is not None: attributes[ATTR_OPERATION_AUTORELOCK] = self._operated_autorelock @@ -219,6 +231,10 @@ class AugustOperatorSensor(AugustEntityMixin, RestoreEntity, SensorEntity): attributes[ATTR_OPERATION_METHOD] = OPERATION_METHOD_REMOTE elif self._operated_keypad: attributes[ATTR_OPERATION_METHOD] = OPERATION_METHOD_KEYPAD + elif self._operated_manual: + attributes[ATTR_OPERATION_METHOD] = OPERATION_METHOD_MANUAL + elif self._operated_tag: + attributes[ATTR_OPERATION_METHOD] = OPERATION_METHOD_TAG elif self._operated_autorelock: attributes[ATTR_OPERATION_METHOD] = OPERATION_METHOD_AUTORELOCK else: @@ -241,6 +257,10 @@ class AugustOperatorSensor(AugustEntityMixin, RestoreEntity, SensorEntity): self._operated_remote = last_state.attributes[ATTR_OPERATION_REMOTE] if ATTR_OPERATION_KEYPAD in last_state.attributes: self._operated_keypad = last_state.attributes[ATTR_OPERATION_KEYPAD] + if ATTR_OPERATION_MANUAL in last_state.attributes: + self._operated_manual = last_state.attributes[ATTR_OPERATION_MANUAL] + if ATTR_OPERATION_TAG in last_state.attributes: + self._operated_tag = last_state.attributes[ATTR_OPERATION_TAG] if ATTR_OPERATION_AUTORELOCK in last_state.attributes: self._operated_autorelock = last_state.attributes[ATTR_OPERATION_AUTORELOCK] diff --git a/homeassistant/components/aurora/__init__.py b/homeassistant/components/aurora/__init__.py index 6ffba5f13da..cf7b48412a7 100644 --- a/homeassistant/components/aurora/__init__.py +++ b/homeassistant/components/aurora/__init__.py @@ -5,7 +5,7 @@ import logging from auroranoaa import AuroraForecast from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, Platform +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client @@ -29,11 +29,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: longitude = conf[CONF_LONGITUDE] latitude = conf[CONF_LATITUDE] threshold = options.get(CONF_THRESHOLD, DEFAULT_THRESHOLD) - name = conf[CONF_NAME] coordinator = AuroraDataUpdateCoordinator( hass=hass, - name=name, api=api, latitude=latitude, longitude=longitude, diff --git a/homeassistant/components/aurora/config_flow.py b/homeassistant/components/aurora/config_flow.py index bbd0768e74a..8fa4b285758 100644 --- a/homeassistant/components/aurora/config_flow.py +++ b/homeassistant/components/aurora/config_flow.py @@ -1,4 +1,4 @@ -"""Config flow for SpaceX Launches and Starman.""" +"""Config flow for Aurora.""" from __future__ import annotations import logging @@ -8,7 +8,7 @@ from auroranoaa import AuroraForecast import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import callback from homeassistant.helpers import aiohttp_client, config_validation as cv from homeassistant.helpers.schema_config_entry_flow import ( @@ -16,7 +16,7 @@ from homeassistant.helpers.schema_config_entry_flow import ( SchemaOptionsFlowHandler, ) -from .const import CONF_THRESHOLD, DEFAULT_NAME, DEFAULT_THRESHOLD, DOMAIN +from .const import CONF_THRESHOLD, DEFAULT_THRESHOLD, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -50,7 +50,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors = {} if user_input is not None: - name = user_input[CONF_NAME] longitude = user_input[CONF_LONGITUDE] latitude = user_input[CONF_LATITUDE] @@ -70,7 +69,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) self._abort_if_unique_id_configured() return self.async_create_entry( - title=f"Aurora - {name}", data=user_input + title="Aurora visibility", data=user_input ) return self.async_show_form( @@ -78,13 +77,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): data_schema=self.add_suggested_values_to_schema( vol.Schema( { - vol.Required(CONF_NAME): str, vol.Required(CONF_LONGITUDE): cv.longitude, vol.Required(CONF_LATITUDE): cv.latitude, } ), { - CONF_NAME: DEFAULT_NAME, CONF_LONGITUDE: self.hass.config.longitude, CONF_LATITUDE: self.hass.config.latitude, }, diff --git a/homeassistant/components/aurora/const.py b/homeassistant/components/aurora/const.py index 419a3c946e6..fef0b5e6352 100644 --- a/homeassistant/components/aurora/const.py +++ b/homeassistant/components/aurora/const.py @@ -6,4 +6,3 @@ AURORA_API = "aurora_api" CONF_THRESHOLD = "forecast_threshold" DEFAULT_THRESHOLD = 75 ATTRIBUTION = "Data provided by the National Oceanic and Atmospheric Administration" -DEFAULT_NAME = "Aurora Visibility" diff --git a/homeassistant/components/aurora/coordinator.py b/homeassistant/components/aurora/coordinator.py index 0ab1be00902..9d4eb0aa681 100644 --- a/homeassistant/components/aurora/coordinator.py +++ b/homeassistant/components/aurora/coordinator.py @@ -18,7 +18,6 @@ class AuroraDataUpdateCoordinator(DataUpdateCoordinator): def __init__( self, hass: HomeAssistant, - name: str, api: AuroraForecast, latitude: float, longitude: float, @@ -29,12 +28,11 @@ class AuroraDataUpdateCoordinator(DataUpdateCoordinator): super().__init__( hass=hass, logger=_LOGGER, - name=name, + name="Aurora", update_interval=timedelta(minutes=5), ) self.api = api - self.name = name self.latitude = int(latitude) self.longitude = int(longitude) self.threshold = int(threshold) diff --git a/homeassistant/components/aurora/entity.py b/homeassistant/components/aurora/entity.py index a52f523f667..1b7dfbe88e3 100644 --- a/homeassistant/components/aurora/entity.py +++ b/homeassistant/components/aurora/entity.py @@ -29,14 +29,9 @@ class AuroraEntity(CoordinatorEntity[AuroraDataUpdateCoordinator]): self._attr_translation_key = translation_key self._attr_unique_id = f"{coordinator.latitude}_{coordinator.longitude}" self._attr_icon = icon - - @property - def device_info(self) -> DeviceInfo: - """Define the device based on name.""" - return DeviceInfo( + self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, str(self.unique_id))}, + identifiers={(DOMAIN, self._attr_unique_id)}, manufacturer="NOAA", model="Aurora Visibility Sensor", - name=self.coordinator.name, ) diff --git a/homeassistant/components/aussie_broadband/__init__.py b/homeassistant/components/aussie_broadband/__init__.py index 1bdb0579976..093480afd7d 100644 --- a/homeassistant/components/aussie_broadband/__init__.py +++ b/homeassistant/components/aussie_broadband/__init__.py @@ -3,11 +3,10 @@ from __future__ import annotations from datetime import timedelta import logging -from typing import Any from aiohttp import ClientError from aussiebb.asyncio import AussieBB -from aussiebb.const import FETCH_TYPES, NBN_TYPES, PHONE_TYPES +from aussiebb.const import FETCH_TYPES from aussiebb.exceptions import AuthenticationException, UnrecognisedServiceType from homeassistant.config_entries import ConfigEntry @@ -23,19 +22,6 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.SENSOR] -# Backport for the pyaussiebb=0.0.15 validate_service_type method -def validate_service_type(service: dict[str, Any]) -> None: - """Check the service types against known types.""" - - if "type" not in service: - raise ValueError("Field 'type' not found in service data") - if service["type"] not in NBN_TYPES + PHONE_TYPES + ["Hardware"]: - raise UnrecognisedServiceType( - f"Service type {service['type']=} {service['name']=} - not recognised - ", - "please report this at https://github.com/yaleman/aussiebb/issues/new", - ) - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Aussie Broadband from a config entry.""" # Login to the Aussie Broadband API and retrieve the current service list @@ -44,9 +30,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.data[CONF_PASSWORD], async_get_clientsession(hass), ) - # Overwrite the pyaussiebb=0.0.15 validate_service_type method with backport - # Required until pydantic 2.x is supported - client.validate_service_type = validate_service_type + try: await client.login() services = await client.get_services(drop_types=FETCH_TYPES) @@ -61,10 +45,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: return await client.get_usage(service_id) except UnrecognisedServiceType as err: - raise UpdateFailed( - f"Service {service_id} of type '{services[service_id]['type']}' was" - " unrecognised" - ) from err + raise UpdateFailed(f"Service {service_id} was unrecognised") from err return async_update_data diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index f4db7831235..df388e52a7f 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -57,9 +57,6 @@ from homeassistant.helpers import condition 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 async_setup(hass: HomeAssistant, config: ConfigType) -> bool: 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 5b389a3fc26..8f5d3f957f9 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 0798a051173..e1e3bd5b2f6 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 3083d271d1f..00000000000 --- 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 083c7d48b03..cb974707e93 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 00000000000..b687a916a2d --- /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 27962167330..2a09a8d4e70 100644 --- a/homeassistant/components/awair/sensor.py +++ b/homeassistant/components/awair/sensor.py @@ -31,7 +31,6 @@ from homeassistant.helpers.device_registry import DeviceInfo 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 @@ from .const import ( 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 bbae3914533..9edb23abcf8 100644 --- a/homeassistant/components/baf/config_flow.py +++ b/homeassistant/components/baf/config_flow.py @@ -14,7 +14,6 @@ from homeassistant import config_entries 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 @@ class BAFFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): 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 445a84f838c..d3b2878b522 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 @@ class BlinkConfigFlow(ConfigFlow, domain=DOMAIN): """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 35c9a40a46a..4361af9ad37 100644 --- a/homeassistant/components/bloomsky/sensor.py +++ b/homeassistant/components/bloomsky/sensor.py @@ -100,6 +100,7 @@ class BloomSkySensor(SensorEntity): 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 @@ class BloomSkySensor(SensorEntity): 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/__init__.py b/homeassistant/components/bluetooth/__init__.py index 2e0e62440ab..c59249e8bd5 100644 --- a/homeassistant/components/bluetooth/__init__.py +++ b/homeassistant/components/bluetooth/__init__.py @@ -45,6 +45,8 @@ from .api import ( async_ble_device_from_address, async_discovered_service_info, async_get_advertisement_callback, + async_get_fallback_availability_interval, + async_get_learned_advertising_interval, async_get_scanner, async_last_service_info, async_process_advertisements, @@ -54,6 +56,7 @@ from .api import ( async_scanner_by_source, async_scanner_count, async_scanner_devices_by_address, + async_set_fallback_availability_interval, async_track_unavailable, ) from .base_scanner import BaseHaRemoteScanner, BaseHaScanner, BluetoothScannerDevice @@ -86,12 +89,15 @@ __all__ = [ "async_address_present", "async_ble_device_from_address", "async_discovered_service_info", + "async_get_fallback_availability_interval", + "async_get_learned_advertising_interval", "async_get_scanner", "async_last_service_info", "async_process_advertisements", "async_rediscover_address", "async_register_callback", "async_register_scanner", + "async_set_fallback_availability_interval", "async_track_unavailable", "async_scanner_by_source", "async_scanner_count", diff --git a/homeassistant/components/bluetooth/active_update_coordinator.py b/homeassistant/components/bluetooth/active_update_coordinator.py index 5fa05b87cc8..cdf51d34978 100644 --- a/homeassistant/components/bluetooth/active_update_coordinator.py +++ b/homeassistant/components/bluetooth/active_update_coordinator.py @@ -110,7 +110,7 @@ class ActiveBluetoothDataUpdateCoordinator( 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 8e38191c820..a3f5e20a9e9 100644 --- a/homeassistant/components/bluetooth/active_update_processor.py +++ b/homeassistant/components/bluetooth/active_update_processor.py @@ -103,7 +103,7 @@ class ActiveBluetoothProcessorCoordinator( 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 be35a9d255d..9d24428e3d2 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( @@ -197,3 +197,27 @@ def async_get_advertisement_callback( ) -> Callable[[BluetoothServiceInfoBleak], None]: """Get the advertisement callback.""" return _get_manager(hass).scanner_adv_received + + +@hass_callback +def async_get_learned_advertising_interval( + hass: HomeAssistant, address: str +) -> float | None: + """Get the learned advertising interval for a MAC address.""" + return _get_manager(hass).async_get_learned_advertising_interval(address) + + +@hass_callback +def async_get_fallback_availability_interval( + hass: HomeAssistant, address: str +) -> float | None: + """Get the fallback availability timeout for a MAC address.""" + return _get_manager(hass).async_get_fallback_availability_interval(address) + + +@hass_callback +def async_set_fallback_availability_interval( + hass: HomeAssistant, address: str, interval: float +) -> None: + """Override the fallback availability timeout for a MAC address.""" + _get_manager(hass).async_set_fallback_availability_interval(address, interval) diff --git a/homeassistant/components/bluetooth/base_scanner.py b/homeassistant/components/bluetooth/base_scanner.py index 455619182ab..240610e4868 100644 --- a/homeassistant/components/bluetooth/base_scanner.py +++ b/homeassistant/components/bluetooth/base_scanner.py @@ -131,6 +131,9 @@ class BaseHaScanner(ABC): self.name, SCANNER_WATCHDOG_TIMEOUT, ) + self.scanning = False + return + self.scanning = not self._connecting @contextmanager def connecting(self) -> Generator[None, None, None]: @@ -302,6 +305,7 @@ class BaseHaRemoteScanner(BaseHaScanner): advertisement_monotonic_time: float, ) -> None: """Call the registered callback.""" + self.scanning = not self._connecting self._last_detection = advertisement_monotonic_time try: prev_discovery = self._discovered_device_advertisement_datas[address] diff --git a/homeassistant/components/bluetooth/manager.py b/homeassistant/components/bluetooth/manager.py index bd91c622316..d69558fe7fd 100644 --- a/homeassistant/components/bluetooth/manager.py +++ b/homeassistant/components/bluetooth/manager.py @@ -18,7 +18,7 @@ from bluetooth_adapters import ( ) from homeassistant import config_entries -from homeassistant.components.logger import EVENT_LOGGING_CHANGED +from homeassistant.const import EVENT_LOGGING_CHANGED from homeassistant.core import ( CALLBACK_TYPE, Event, @@ -108,6 +108,7 @@ class BluetoothManager: "_cancel_unavailable_tracking", "_cancel_logging_listener", "_advertisement_tracker", + "_fallback_intervals", "_unavailable_callbacks", "_connectable_unavailable_callbacks", "_callback_index", @@ -139,6 +140,7 @@ class BluetoothManager: self._cancel_logging_listener: CALLBACK_TYPE | None = None self._advertisement_tracker = AdvertisementTracker() + self._fallback_intervals: dict[str, float] = {} self._unavailable_callbacks: dict[ str, list[Callable[[BluetoothServiceInfoBleak], None]] @@ -342,7 +344,9 @@ class BluetoothManager: # since it may have gone to sleep and since we do not need an active # connection to it we can only determine its availability # by the lack of advertisements - if advertising_interval := intervals.get(address): + if advertising_interval := ( + intervals.get(address) or self._fallback_intervals.get(address) + ): advertising_interval += TRACKER_BUFFERING_WOBBLE_SECONDS else: advertising_interval = ( @@ -355,6 +359,7 @@ class BluetoothManager: # The second loop (connectable=False) is responsible for removing # the device from all the interval tracking since it is no longer # available for both connectable and non-connectable + self._fallback_intervals.pop(address, None) tracker.async_remove_address(address) self._integration_matcher.async_clear_address(address) self._async_dismiss_discoveries(address) @@ -386,7 +391,10 @@ class BluetoothManager: """Prefer previous advertisement from a different source if it is better.""" if new.time - old.time > ( stale_seconds := self._advertisement_tracker.intervals.get( - new.address, FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + new.address, + self._fallback_intervals.get( + new.address, FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + ), ) ): # If the old advertisement is stale, any new advertisement is preferred @@ -779,3 +787,20 @@ class BluetoothManager: def async_allocate_connection_slot(self, device: BLEDevice) -> bool: """Allocate a connection slot.""" return self.slot_manager.allocate_slot(device) + + @hass_callback + def async_get_learned_advertising_interval(self, address: str) -> float | None: + """Get the learned advertising interval for a MAC address.""" + return self._advertisement_tracker.intervals.get(address) + + @hass_callback + def async_get_fallback_availability_interval(self, address: str) -> float | None: + """Get the fallback availability timeout for a MAC address.""" + return self._fallback_intervals.get(address) + + @hass_callback + def async_set_fallback_availability_interval( + self, address: str, interval: float + ) -> None: + """Override the fallback availability timeout for a MAC address.""" + self._fallback_intervals[address] = interval diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 33ec71065db..2e2d6fa45ed 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -3,7 +3,7 @@ "name": "Bluetooth", "codeowners": ["@bdraco"], "config_flow": true, - "dependencies": ["logger", "usb"], + "dependencies": ["usb"], "documentation": "https://www.home-assistant.io/integrations/bluetooth", "iot_class": "local_push", "loggers": [ @@ -15,10 +15,10 @@ "quality_scale": "internal", "requirements": [ "bleak==0.21.1", - "bleak-retry-connector==3.1.3", + "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==1.95.2" + "bluetooth-data-tools==1.12.0", + "dbus-fast==2.11.0" ] } diff --git a/homeassistant/components/bluetooth/passive_update_coordinator.py b/homeassistant/components/bluetooth/passive_update_coordinator.py index 6f1749aeef2..fcf6fcdf255 100644 --- a/homeassistant/components/bluetooth/passive_update_coordinator.py +++ b/homeassistant/components/bluetooth/passive_update_coordinator.py @@ -85,6 +85,7 @@ class PassiveBluetoothDataUpdateCoordinator( 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 20b992d06d6..7294d55f912 100644 --- a/homeassistant/components/bluetooth/passive_update_processor.py +++ b/homeassistant/components/bluetooth/passive_update_processor.py @@ -341,7 +341,8 @@ class PassiveBluetoothProcessorCoordinator( 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 @@ class PassiveBluetoothProcessorCoordinator( 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 @@ class PassiveBluetoothDataProcessor(Generic[_T]): @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 @@ class PassiveBluetoothDataProcessor(Generic[_T]): ) 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/scanner.py b/homeassistant/components/bluetooth/scanner.py index eb3ce11b644..896d9dc7958 100644 --- a/homeassistant/components/bluetooth/scanner.py +++ b/homeassistant/components/bluetooth/scanner.py @@ -329,6 +329,9 @@ class HaScanner(BaseHaScanner): self.name, SCANNER_WATCHDOG_TIMEOUT, ) + # Immediately mark the scanner as not scanning + # since the restart task will have to wait for the lock + self.scanning = False self.hass.async_create_task(self._async_restart_scanner()) async def _async_restart_scanner(self) -> None: diff --git a/homeassistant/components/bluetooth/update_coordinator.py b/homeassistant/components/bluetooth/update_coordinator.py index 9c38bf2f520..12bff3be645 100644 --- a/homeassistant/components/bluetooth/update_coordinator.py +++ b/homeassistant/components/bluetooth/update_coordinator.py @@ -39,6 +39,8 @@ class BasePassiveBluetoothCoordinator(ABC): 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 @@ class BasePassiveBluetoothCoordinator(ABC): """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_tracker/device_tracker.py b/homeassistant/components/bluetooth_tracker/device_tracker.py index 4bfbe72d8b5..6fecc428c10 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 8f5b4fb8608..62854badb20 100644 --- a/homeassistant/components/bmw_connected_drive/sensor.py +++ b/homeassistant/components/bmw_connected_drive/sensor.py @@ -13,6 +13,7 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import LENGTH, PERCENTAGE, VOLUME, UnitOfElectricCurrent @@ -94,6 +95,7 @@ SENSOR_TYPES: dict[str, BMWSensorEntityDescription] = { key_class="fuel_and_battery", unit_type=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, ), # --- Specific --- "mileage": BMWSensorEntityDescription( @@ -102,6 +104,7 @@ SENSOR_TYPES: dict[str, BMWSensorEntityDescription] = { 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 @@ SENSOR_TYPES: dict[str, BMWSensorEntityDescription] = { 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 @@ SENSOR_TYPES: dict[str, BMWSensorEntityDescription] = { 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 @@ SENSOR_TYPES: dict[str, BMWSensorEntityDescription] = { 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 @@ SENSOR_TYPES: dict[str, BMWSensorEntityDescription] = { 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 @@ SENSOR_TYPES: dict[str, BMWSensorEntityDescription] = { 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 3b3ace98950..03a5f444579 100644 --- a/homeassistant/components/bond/entity.py +++ b/homeassistant/components/bond/entity.py @@ -17,7 +17,7 @@ from homeassistant.const import ( 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 @@ class BondEntity(Entity): 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 @@ class BondEntity(Entity): 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 67462b78bec..90688e1373f 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 9b89c667b3c..20b30d1dd11 100644 --- a/homeassistant/components/braviatv/coordinator.py +++ b/homeassistant/components/braviatv/coordinator.py @@ -191,9 +191,11 @@ class BraviaTVCoordinator(DataUpdateCoordinator[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/broadlink/light.py b/homeassistant/components/broadlink/light.py index 796698c6a4c..fde6d322bc6 100644 --- a/homeassistant/components/broadlink/light.py +++ b/homeassistant/components/broadlink/light.py @@ -6,8 +6,7 @@ from broadlink.exceptions import BroadlinkException from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_MODE, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, ColorMode, LightEntity, @@ -46,6 +45,8 @@ class BroadlinkLight(BroadlinkEntity, LightEntity): _attr_has_entity_name = True _attr_name = None + _attr_min_color_temp_kelvin = 2700 + _attr_max_color_temp_kelvin = 6500 def __init__(self, device): """Initialize the light.""" @@ -80,7 +81,7 @@ class BroadlinkLight(BroadlinkEntity, LightEntity): self._attr_hs_color = [data["hue"], data["saturation"]] if "colortemp" in data: - self._attr_color_temp = round((data["colortemp"] - 2700) / 100 + 153) + self._attr_color_temp_kelvin = data["colortemp"] if "bulb_colormode" in data: if data["bulb_colormode"] == BROADLINK_COLOR_MODE_RGB: @@ -108,21 +109,11 @@ class BroadlinkLight(BroadlinkEntity, LightEntity): state["saturation"] = int(hs_color[1]) state["bulb_colormode"] = BROADLINK_COLOR_MODE_RGB - elif ATTR_COLOR_TEMP in kwargs: - color_temp = kwargs[ATTR_COLOR_TEMP] - state["colortemp"] = (color_temp - 153) * 100 + 2700 + elif ATTR_COLOR_TEMP_KELVIN in kwargs: + color_temp = kwargs[ATTR_COLOR_TEMP_KELVIN] + state["colortemp"] = color_temp state["bulb_colormode"] = BROADLINK_COLOR_MODE_WHITE - elif ATTR_COLOR_MODE in kwargs: - color_mode = kwargs[ATTR_COLOR_MODE] - if color_mode == ColorMode.HS: - state["bulb_colormode"] = BROADLINK_COLOR_MODE_RGB - elif color_mode == ColorMode.COLOR_TEMP: - state["bulb_colormode"] = BROADLINK_COLOR_MODE_WHITE - else: - # Scenes are not yet supported. - state["bulb_colormode"] = BROADLINK_COLOR_MODE_SCENES - await self._async_set_state(state) async def async_turn_off(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/bsblan/coordinator.py b/homeassistant/components/bsblan/coordinator.py index 9344500a118..15eff37e6db 100644 --- a/homeassistant/components/bsblan/coordinator.py +++ b/homeassistant/components/bsblan/coordinator.py @@ -35,7 +35,8 @@ class BSBLanUpdateCoordinator(DataUpdateCoordinator[State]): LOGGER, name=f"{DOMAIN}_{config_entry.data[CONF_HOST]}", # use the default scan interval and add a random number of seconds to avoid timeouts when - # the BSB-Lan device is already/still busy retrieving data, e.g. for MQTT or internal logging. + # the BSB-Lan device is already/still busy retrieving data, + # e.g. for MQTT or internal logging. update_interval=SCAN_INTERVAL + timedelta(seconds=randint(1, 8)), ) @@ -50,5 +51,6 @@ class BSBLanUpdateCoordinator(DataUpdateCoordinator[State]): return await self.client.state() except BSBLANConnectionError as err: raise UpdateFailed( - f"Error while establishing connection with BSB-Lan device at {self.config_entry.data[CONF_HOST]}" + f"Error while establishing connection with " + f"BSB-Lan device at {self.config_entry.data[CONF_HOST]}" ) from err diff --git a/homeassistant/components/bthome/manifest.json b/homeassistant/components/bthome/manifest.json index 7f53a5b5f06..01db154306f 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 86e650aefed..439921928d6 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 @@ class BuienradarCam(Camera): """ super().__init__() - self._name = "Buienradar" - # dimension (x and y) of returned radar image self._dimension = DEFAULT_DIMENSION @@ -94,12 +95,7 @@ class BuienradarCam(Camera): # 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 @@ class BuienradarCam(Camera): 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 e487569453f..1622f568a2d 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 @@ -529,6 +531,7 @@ class CalendarEntity(Entity): for unsub in self._alarm_unsubs: unsub() + self._alarm_unsubs.clear() now = dt_util.now() event = self.event @@ -538,6 +541,7 @@ class CalendarEntity(Entity): @callback def update(_: datetime.datetime) -> None: """Run when the active or upcoming event starts or ends.""" + _LOGGER.debug("Running %s update", self.entity_id) self._async_write_ha_state() if now < event.start_datetime_local: @@ -551,6 +555,13 @@ class CalendarEntity(Entity): self._alarm_unsubs.append( async_track_point_in_time(self.hass, update, event.end_datetime_local) ) + _LOGGER.debug( + "Scheduled %d updates for %s (%s, %s)", + len(self._alarm_unsubs), + self.entity_id, + event.start_datetime_local, + event.end_datetime_local, + ) async def async_will_remove_from_hass(self) -> None: """Run when entity will be removed from hass. @@ -559,6 +570,7 @@ class CalendarEntity(Entity): """ for unsub in self._alarm_unsubs: unsub() + self._alarm_unsubs.clear() async def async_get_events( self, diff --git a/homeassistant/components/calendar/recorder.py b/homeassistant/components/calendar/recorder.py deleted file mode 100644 index 4aba7b409cc..00000000000 --- 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 07394ca75b2..bb5a44a530c 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 5c141220881..00000000000 --- 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 c6a92c21fb4..8b8862ab318 100644 --- a/homeassistant/components/cast/helpers.py +++ b/homeassistant/components/cast/helpers.py @@ -214,7 +214,7 @@ class CastStatusListener( 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 5d1e68a951f..391bb3ef8f3 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_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 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 00000000000..6a125758f70 --- /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 907ff84491b..a075467a313 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 @@ class ClimateEntity(Entity): 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 @@ class ClimateEntity(Entity): """ 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 d9f1b240a9a..57b9654651b 100644 --- a/homeassistant/components/climate/device_condition.py +++ b/homeassistant/components/climate/device_condition.py @@ -92,9 +92,9 @@ def async_condition_from_config( 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 879e6bfbbac..00000000000 --- 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 0bbc6fce7ec..2897a956fc6 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 c517bfd7a20..55ccef2bc76 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/google_config.py b/homeassistant/components/cloud/google_config.py index 0a49c0b6ed6..c11ec47b2e5 100644 --- a/homeassistant/components/cloud/google_config.py +++ b/homeassistant/components/cloud/google_config.py @@ -345,14 +345,16 @@ class CloudGoogleConfig(AbstractConfig): assistant_options = settings.get(CLOUD_GOOGLE, {}) return not assistant_options.get(PREF_DISABLE_2FA, DEFAULT_DISABLE_2FA) - async def async_report_state(self, message: Any, agent_user_id: str) -> None: + async def async_report_state( + self, message: Any, agent_user_id: str, event_id: str | None = None + ) -> None: """Send a state report to Google.""" try: await self._cloud.google_report_state.async_send_message(message) except ErrorResponse as err: _LOGGER.warning("Error reporting state - %s: %s", err.code, err.message) - async def _async_request_sync_devices(self, agent_user_id: str) -> int: + async def _async_request_sync_devices(self, agent_user_id: str) -> HTTPStatus | int: """Trigger a sync with Google.""" if self._sync_entities_lock.locked(): return HTTPStatus.OK diff --git a/homeassistant/components/co2signal/__init__.py b/homeassistant/components/co2signal/__init__.py index 721a26e147f..04ae811197b 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 036282cb3e8..92b09b6e17a 100644 --- a/homeassistant/components/co2signal/config_flow.py +++ b/homeassistant/components/co2signal/config_flow.py @@ -10,8 +10,9 @@ from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE 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 00000000000..24d7bbd18af --- /dev/null +++ b/homeassistant/components/co2signal/coordinator.py @@ -0,0 +1,94 @@ +"""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 requests.exceptions import JSONDecodeError + +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 JSONDecodeError as err: + # raise occasional occurring json decoding errors as CO2Error so the data update coordinator retries it + raise CO2Error from err + + 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 8ab09b8cb75..db08aa4eca6 100644 --- a/homeassistant/components/co2signal/diagnostics.py +++ b/homeassistant/components/co2signal/diagnostics.py @@ -8,7 +8,8 @@ from homeassistant.config_entries import ConfigEntry 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 00000000000..cc8ee709bde --- /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 a0a3ee71a9c..a4d7c55d6da 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 00000000000..758bb15c5f0 --- /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 c5bc7eb4c20..d00bdf70d3e 100644 --- a/homeassistant/components/co2signal/sensor.py +++ b/homeassistant/components/co2signal/sensor.py @@ -17,8 +17,8 @@ from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo 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 01c5673d4b1..7dbcd2e7966 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/color_extractor/__init__.py b/homeassistant/components/color_extractor/__init__.py index fb04ebb76a4..af460f819cd 100644 --- a/homeassistant/components/color_extractor/__init__.py +++ b/homeassistant/components/color_extractor/__init__.py @@ -13,6 +13,7 @@ from homeassistant.components.light import ( DOMAIN as LIGHT_DOMAIN, LIGHT_TURN_ON_SCHEMA, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import SERVICE_TURN_ON as LIGHT_SERVICE_TURN_ON from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import aiohttp_client @@ -58,8 +59,20 @@ def _get_color(file_handler) -> tuple: return color -async def async_setup(hass: HomeAssistant, hass_config: ConfigType) -> bool: - """Set up services for color_extractor integration.""" +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Color extractor component.""" + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data={} + ) + ) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Load a config entry.""" async def async_handle_service(service_call: ServiceCall) -> None: """Decide which color_extractor method to call based on service.""" diff --git a/homeassistant/components/color_extractor/config_flow.py b/homeassistant/components/color_extractor/config_flow.py new file mode 100644 index 00000000000..32b803d14f9 --- /dev/null +++ b/homeassistant/components/color_extractor/config_flow.py @@ -0,0 +1,64 @@ +"""Config flow to configure the Color extractor integration.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.config_entries import ConfigFlow +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN +from homeassistant.data_entry_flow import FlowResult, FlowResultType +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue + +from .const import DEFAULT_NAME, DOMAIN + + +class ColorExtractorConfigFlow(ConfigFlow, domain=DOMAIN): + """Config flow for Color extractor.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initialized by the user.""" + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + + if user_input is not None: + return self.async_create_entry(title=DEFAULT_NAME, data={}) + + return self.async_show_form(step_id="user") + + async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: + """Handle import from configuration.yaml.""" + result = await self.async_step_user(user_input) + if result["type"] == FlowResultType.CREATE_ENTRY: + 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": "Color extractor", + }, + ) + else: + async_create_issue( + self.hass, + DOMAIN, + "deprecated_yaml", + 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": "Color extractor", + }, + ) + return result diff --git a/homeassistant/components/color_extractor/const.py b/homeassistant/components/color_extractor/const.py index a6c59ea434b..e783dcb533d 100644 --- a/homeassistant/components/color_extractor/const.py +++ b/homeassistant/components/color_extractor/const.py @@ -3,5 +3,6 @@ ATTR_PATH = "color_extract_path" ATTR_URL = "color_extract_url" DOMAIN = "color_extractor" +DEFAULT_NAME = "Color extractor" SERVICE_TURN_ON = "turn_on" diff --git a/homeassistant/components/color_extractor/manifest.json b/homeassistant/components/color_extractor/manifest.json index 07e9b43a5e5..c87ac2540a6 100644 --- a/homeassistant/components/color_extractor/manifest.json +++ b/homeassistant/components/color_extractor/manifest.json @@ -2,7 +2,7 @@ "domain": "color_extractor", "name": "ColorExtractor", "codeowners": ["@GenericStudent"], - "config_flow": false, + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/color_extractor", "requirements": ["colorthief==0.2.1"] } diff --git a/homeassistant/components/color_extractor/strings.json b/homeassistant/components/color_extractor/strings.json index 3dc02f56030..aa5fd5f4ef7 100644 --- a/homeassistant/components/color_extractor/strings.json +++ b/homeassistant/components/color_extractor/strings.json @@ -1,4 +1,20 @@ { + "config": { + "step": { + "user": { + "description": "[%key:common::config_flow::description::confirm_setup%]" + } + }, + "abort": { + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" + } + }, + "issues": { + "deprecated_yaml": { + "title": "The {integration_title} YAML configuration is being removed", + "description": "Configuring {integration_title} using YAML is being removed.\n\nYour configuration is already imported.\n\nRemove the `{domain}` configuration from your configuration.yaml file and restart Home Assistant to fix this issue." + } + }, "services": { "turn_on": { "name": "[%key:common::action::turn_on%]", diff --git a/homeassistant/components/comelit/__init__.py b/homeassistant/components/comelit/__init__.py index 2c73922582c..4a105072802 100644 --- a/homeassistant/components/comelit/__init__.py +++ b/homeassistant/components/comelit/__init__.py @@ -7,7 +7,7 @@ from homeassistant.core import HomeAssistant from .const import DOMAIN from .coordinator import ComelitSerialBridge -PLATFORMS = [Platform.LIGHT] +PLATFORMS = [Platform.COVER, Platform.LIGHT] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/comelit/coordinator.py b/homeassistant/components/comelit/coordinator.py index df1d745ce8a..a9c281c10c0 100644 --- a/homeassistant/components/comelit/coordinator.py +++ b/homeassistant/components/comelit/coordinator.py @@ -71,16 +71,14 @@ class ComelitSerialBridge(DataUpdateCoordinator): async def _async_update_data(self) -> dict[str, Any]: """Update router data.""" _LOGGER.debug("Polling Comelit Serial Bridge host: %s", self._host) + logged = False try: logged = await self.api.login() except (asyncio.exceptions.TimeoutError, aiohttp.ClientConnectorError) as err: _LOGGER.warning("Connection error for %s", self._host) raise UpdateFailed(f"Error fetching data: {repr(err)}") from err + finally: + if not logged: + raise ConfigEntryAuthFailed - if not logged: - raise ConfigEntryAuthFailed - - devices_data = await self.api.get_all_devices() - await self.api.logout() - - return devices_data + return await self.api.get_all_devices() diff --git a/homeassistant/components/comelit/cover.py b/homeassistant/components/comelit/cover.py new file mode 100644 index 00000000000..0135fa3984a --- /dev/null +++ b/homeassistant/components/comelit/cover.py @@ -0,0 +1,101 @@ +"""Support for covers.""" +from __future__ import annotations + +from typing import Any + +from aiocomelit import ComelitSerialBridgeObject +from aiocomelit.const import COVER, COVER_CLOSE, COVER_OPEN, COVER_STATUS + +from homeassistant.components.cover import CoverDeviceClass, CoverEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import ComelitSerialBridge + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Comelit covers.""" + + coordinator: ComelitSerialBridge = hass.data[DOMAIN][config_entry.entry_id] + + # Use config_entry.entry_id as base for unique_id because no serial number or mac is available + async_add_entities( + ComelitCoverEntity(coordinator, device, config_entry.entry_id) + for device in coordinator.data[COVER].values() + ) + + +class ComelitCoverEntity(CoordinatorEntity[ComelitSerialBridge], CoverEntity): + """Cover device.""" + + _attr_device_class = CoverDeviceClass.SHUTTER + _attr_has_entity_name = True + _attr_name = None + + def __init__( + self, + coordinator: ComelitSerialBridge, + device: ComelitSerialBridgeObject, + config_entry_entry_id: str, + ) -> None: + """Init cover entity.""" + self._api = coordinator.api + self._device = device + super().__init__(coordinator) + self._attr_unique_id = f"{config_entry_entry_id}-{device.index}" + self._attr_device_info = coordinator.platform_device_info(device, COVER) + # Device doesn't provide a status so we assume CLOSE at startup + self._last_action = COVER_STATUS.index("closing") + + def _current_action(self, action: str) -> bool: + """Return the current cover action.""" + is_moving = self.device_status == COVER_STATUS.index(action) + if is_moving: + self._last_action = COVER_STATUS.index(action) + return is_moving + + @property + def device_status(self) -> int: + """Return current device status.""" + return self.coordinator.data[COVER][self._device.index].status + + @property + def is_closed(self) -> bool: + """Return True if cover is closed.""" + if self.device_status != COVER_STATUS.index("stopped"): + return False + + return bool(self._last_action == COVER_STATUS.index("closing")) + + @property + def is_closing(self) -> bool: + """Return if the cover is closing.""" + return self._current_action("closing") + + @property + def is_opening(self) -> bool: + """Return if the cover is opening.""" + return self._current_action("opening") + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close cover.""" + await self._api.cover_move(self._device.index, COVER_CLOSE) + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open cover.""" + await self._api.cover_move(self._device.index, COVER_OPEN) + + async def async_stop_cover(self, **_kwargs: Any) -> None: + """Stop the cover.""" + if not self.is_closing and not self.is_opening: + return + + action = COVER_OPEN if self.is_closing else COVER_CLOSE + await self._api.cover_move(self._device.index, action) diff --git a/homeassistant/components/comelit/light.py b/homeassistant/components/comelit/light.py index a4a534025f0..a59422f7b04 100644 --- a/homeassistant/components/comelit/light.py +++ b/homeassistant/components/comelit/light.py @@ -36,19 +36,19 @@ class ComelitLightEntity(CoordinatorEntity[ComelitSerialBridge], LightEntity): """Light device.""" _attr_has_entity_name = True + _attr_name = None def __init__( self, coordinator: ComelitSerialBridge, device: ComelitSerialBridgeObject, - config_entry_unique_id: str, + config_entry_entry_id: str, ) -> None: """Init light entity.""" self._api = coordinator.api self._device = device super().__init__(coordinator) - self._attr_name = device.name - self._attr_unique_id = f"{config_entry_unique_id}-{device.index}" + self._attr_unique_id = f"{config_entry_entry_id}-{device.index}" self._attr_device_info = self.coordinator.platform_device_info(device, LIGHT) async def _light_set_state(self, state: int) -> None: diff --git a/homeassistant/components/comelit/manifest.json b/homeassistant/components/comelit/manifest.json index ee876434825..3e49996e50e 100644 --- a/homeassistant/components/comelit/manifest.json +++ b/homeassistant/components/comelit/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/comelit", "iot_class": "local_polling", "loggers": ["aiocomelit"], - "requirements": ["aiocomelit==0.0.8"] + "requirements": ["aiocomelit==0.0.9"] } diff --git a/homeassistant/components/compensation/manifest.json b/homeassistant/components/compensation/manifest.json index 7b59879025e..e166ca716cb 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/manifest.json b/homeassistant/components/conversation/manifest.json index 2f733ead486..f11dda15a4e 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -7,5 +7,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["hassil==1.2.5", "home-assistant-intents==2023.9.22"] + "requirements": ["hassil==1.2.5", "home-assistant-intents==2023.10.2"] } diff --git a/homeassistant/components/conversation/services.yaml b/homeassistant/components/conversation/services.yaml index 7b6717eec6d..953db065614 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 15e783c0d90..8240cfa3f82 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 289e70e8067..eaca8949b14 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 00000000000..241f287e297 --- /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/daikin/__init__.py b/homeassistant/components/daikin/__init__.py index f6fd399f855..eda7976e572 100644 --- a/homeassistant/components/daikin/__init__.py +++ b/homeassistant/components/daikin/__init__.py @@ -135,9 +135,11 @@ async def async_migrate_unique_id( ) -> None: """Migrate old entry.""" dev_reg = dr.async_get(hass) + ent_reg = er.async_get(hass) old_unique_id = config_entry.unique_id new_unique_id = api.device.mac - new_name = api.device.values.get("name") + new_mac = dr.format_mac(new_unique_id) + new_name = api.name @callback def _update_unique_id(entity_entry: er.RegistryEntry) -> dict[str, str] | None: @@ -147,15 +149,36 @@ async def async_migrate_unique_id( if new_unique_id == old_unique_id: return + duplicate = dev_reg.async_get_device( + connections={(CONNECTION_NETWORK_MAC, new_mac)}, identifiers=None + ) + + # Remove duplicated device + if duplicate is not None: + if config_entry.entry_id in duplicate.config_entries: + _LOGGER.debug( + "Removing duplicated device %s", + duplicate.name, + ) + + # The automatic cleanup in entity registry is scheduled as a task, remove + # the entities manually to avoid unique_id collision when the entities + # are migrated. + duplicate_entities = er.async_entries_for_device( + ent_reg, duplicate.id, True + ) + for entity in duplicate_entities: + ent_reg.async_remove(entity.entity_id) + + dev_reg.async_remove_device(duplicate.id) + # Migrate devices for device_entry in dr.async_entries_for_config_entry( dev_reg, config_entry.entry_id ): for connection in device_entry.connections: if connection[1] == old_unique_id: - new_connections = { - (CONNECTION_NETWORK_MAC, dr.format_mac(new_unique_id)) - } + new_connections = {(CONNECTION_NETWORK_MAC, new_mac)} _LOGGER.debug( "Migrating device %s connections to %s", diff --git a/homeassistant/components/datetime/__init__.py b/homeassistant/components/datetime/__init__.py index b04008672ae..e25f4535d0c 100644 --- a/homeassistant/components/datetime/__init__.py +++ b/homeassistant/components/datetime/__init__.py @@ -12,7 +12,6 @@ from homeassistant.config_entries import ConfigEntry 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 4fe141c4943..d3ed3564344 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/deconz/cover.py b/homeassistant/components/deconz/cover.py index 3eac9cafd52..012f064dd07 100644 --- a/homeassistant/components/deconz/cover.py +++ b/homeassistant/components/deconz/cover.py @@ -59,16 +59,20 @@ class DeconzCover(DeconzDevice[Cover], CoverEntity): """Set up cover device.""" super().__init__(cover := gateway.api.lights.covers[cover_id], gateway) - self._attr_supported_features = CoverEntityFeature.OPEN - self._attr_supported_features |= CoverEntityFeature.CLOSE - self._attr_supported_features |= CoverEntityFeature.STOP - self._attr_supported_features |= CoverEntityFeature.SET_POSITION + self._attr_supported_features = ( + CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.STOP + | CoverEntityFeature.SET_POSITION + ) if self._device.tilt is not None: - self._attr_supported_features |= CoverEntityFeature.OPEN_TILT - self._attr_supported_features |= CoverEntityFeature.CLOSE_TILT - self._attr_supported_features |= CoverEntityFeature.STOP_TILT - self._attr_supported_features |= CoverEntityFeature.SET_TILT_POSITION + self._attr_supported_features |= ( + CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.STOP_TILT + | CoverEntityFeature.SET_TILT_POSITION + ) self._attr_device_class = DECONZ_TYPE_TO_DEVICE_CLASS.get(cover.type) diff --git a/homeassistant/components/deconz/fan.py b/homeassistant/components/deconz/fan.py index a0d62126b92..278d702d63b 100644 --- a/homeassistant/components/deconz/fan.py +++ b/homeassistant/components/deconz/fan.py @@ -60,7 +60,7 @@ class DeconzFan(DeconzDevice[Light], FanEntity): def __init__(self, device: Light, gateway: DeconzGateway) -> None: """Set up fan.""" super().__init__(device, gateway) - + _attr_speed_count = len(ORDERED_NAMED_FAN_SPEEDS) if device.fan_speed in ORDERED_NAMED_FAN_SPEEDS: self._default_on_speed = device.fan_speed @@ -80,11 +80,6 @@ class DeconzFan(DeconzDevice[Light], FanEntity): ORDERED_NAMED_FAN_SPEEDS, self._device.fan_speed ) - @property - def speed_count(self) -> int: - """Return the number of speeds the fan supports.""" - return len(ORDERED_NAMED_FAN_SPEEDS) - @callback def async_update_callback(self) -> None: """Store latest configured speed from the device.""" diff --git a/homeassistant/components/deconz/light.py b/homeassistant/components/deconz/light.py index 46d10a77271..47ca1eda0d8 100644 --- a/homeassistant/components/deconz/light.py +++ b/homeassistant/components/deconz/light.py @@ -154,8 +154,9 @@ class DeconzBaseLight(DeconzDevice[_LightDeviceT], LightEntity): self._attr_supported_color_modes.add(ColorMode.ONOFF) if device.brightness is not None: - self._attr_supported_features |= LightEntityFeature.FLASH - self._attr_supported_features |= LightEntityFeature.TRANSITION + self._attr_supported_features |= ( + LightEntityFeature.FLASH | LightEntityFeature.TRANSITION + ) if device.effect is not None: self._attr_supported_features |= LightEntityFeature.EFFECT diff --git a/homeassistant/components/denonavr/manifest.json b/homeassistant/components/denonavr/manifest.json index b3c36ed39d2..0ba8caed6c5 100644 --- a/homeassistant/components/denonavr/manifest.json +++ b/homeassistant/components/denonavr/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/denonavr", "iot_class": "local_push", "loggers": ["denonavr"], - "requirements": ["denonavr==0.11.3"], + "requirements": ["denonavr==0.11.4"], "ssdp": [ { "manufacturer": "Denon", diff --git a/homeassistant/components/denonavr/media_player.py b/homeassistant/components/denonavr/media_player.py index c3dfbeb1011..8b6907a60f7 100644 --- a/homeassistant/components/denonavr/media_player.py +++ b/homeassistant/components/denonavr/media_player.py @@ -8,7 +8,15 @@ import logging from typing import Any, Concatenate, ParamSpec, TypeVar from denonavr import DenonAVR -from denonavr.const import POWER_ON, STATE_OFF, STATE_ON, STATE_PAUSED, STATE_PLAYING +from denonavr.const import ( + ALL_TELNET_EVENTS, + ALL_ZONES, + POWER_ON, + STATE_OFF, + STATE_ON, + STATE_PAUSED, + STATE_PLAYING, +) from denonavr.exceptions import ( AvrCommandError, AvrForbiddenError, @@ -19,6 +27,7 @@ from denonavr.exceptions import ( import voluptuous as vol from homeassistant.components.media_player import ( + MediaPlayerDeviceClass, MediaPlayerEntity, MediaPlayerEntityFeature, MediaPlayerState, @@ -72,6 +81,23 @@ SERVICE_GET_COMMAND = "get_command" SERVICE_SET_DYNAMIC_EQ = "set_dynamic_eq" SERVICE_UPDATE_AUDYSSEY = "update_audyssey" +# HA Telnet events +TELNET_EVENTS = { + "HD", + "MS", + "MU", + "MV", + "NS", + "NSE", + "PS", + "SI", + "SS", + "TF", + "ZM", + "Z2", + "Z3", +} + _DenonDeviceT = TypeVar("_DenonDeviceT", bound="DenonDevice") _R = TypeVar("_R") _P = ParamSpec("_P") @@ -218,6 +244,7 @@ class DenonDevice(MediaPlayerEntity): _attr_has_entity_name = True _attr_name = None + _attr_device_class = MediaPlayerDeviceClass.RECEIVER def __init__( self, @@ -238,7 +265,6 @@ class DenonDevice(MediaPlayerEntity): name=receiver.name, ) self._attr_sound_mode_list = receiver.sound_mode_list - self._receiver = receiver self._update_audyssey = update_audyssey @@ -253,7 +279,9 @@ class DenonDevice(MediaPlayerEntity): async def _telnet_callback(self, zone, event, parameter) -> None: """Process a telnet command callback.""" # There are multiple checks implemented which reduce unnecessary updates of the ha state machine - if zone != self._receiver.zone: + if zone not in (self._receiver.zone, ALL_ZONES): + return + if event not in TELNET_EVENTS: return # Some updates trigger multiple events like one for artist and one for title for one change # We skip every event except the last one @@ -267,11 +295,11 @@ class DenonDevice(MediaPlayerEntity): async def async_added_to_hass(self) -> None: """Register for telnet events.""" - self._receiver.register_callback("ALL", self._telnet_callback) + self._receiver.register_callback(ALL_TELNET_EVENTS, self._telnet_callback) async def async_will_remove_from_hass(self) -> None: """Clean up the entity.""" - self._receiver.unregister_callback("ALL", self._telnet_callback) + self._receiver.unregister_callback(ALL_TELNET_EVENTS, self._telnet_callback) @async_log_errors async def async_update(self) -> None: diff --git a/homeassistant/components/device_tracker/device_trigger.py b/homeassistant/components/device_tracker/device_trigger.py index a96f9affb1d..404dad0d4d1 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 227b4796883..e27d5a315a5 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 @@ class DevoloClimateDeviceEntity(DevoloMultiLevelSwitchDeviceEntity, ClimateEntit 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 a23c3fde585..b76948bcee7 100644 --- a/homeassistant/components/devolo_home_control/cover.py +++ b/homeassistant/components/devolo_home_control/cover.py @@ -3,9 +3,6 @@ from __future__ import annotations 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 93a66e345ec..e91466c7ece 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 @@ class DevoloLightDeviceEntity(DevoloMultiLevelSwitchDeviceEntity, LightEntity): 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 b7e2a30b4c1..fa11424ae94 100644 --- a/homeassistant/components/devolo_home_control/sensor.py +++ b/homeassistant/components/devolo_home_control/sensor.py @@ -123,6 +123,12 @@ class DevoloGenericMultiLevelDeviceEntity(DevoloMultiLevelDeviceEntity): 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 @@ class DevoloBatteryEntity(DevoloMultiLevelDeviceEntity): 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 @@ class DevoloConsumptionEntity(DevoloMultiLevelDeviceEntity): @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 9b96e58da60..c442cc55763 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 627a121dcb4..94e848fe8af 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 @@ -36,6 +35,7 @@ from .const import ( CONNECTED_PLC_DEVICES, CONNECTED_WIFI_CLIENTS, DOMAIN, + FIRMWARE_UPDATE_INTERVAL, LONG_UPDATE_INTERVAL, NEIGHBORING_WIFI_NETWORKS, REGULAR_FIRMWARE, @@ -74,8 +74,7 @@ async def async_setup_entry( # noqa: C901 """Fetch data from API endpoint.""" assert device.device try: - async with asyncio.timeout(30): - 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 +82,7 @@ async def async_setup_entry( # noqa: C901 """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 +90,7 @@ async def async_setup_entry( # noqa: C901 """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 +100,7 @@ async def async_setup_entry( # noqa: C901 """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 +108,7 @@ async def async_setup_entry( # noqa: C901 """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 +116,7 @@ async def async_setup_entry( # noqa: C901 """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 @@ -153,7 +147,7 @@ async def async_setup_entry( # noqa: C901 _LOGGER, name=REGULAR_FIRMWARE, update_method=async_update_firmware_available, - update_interval=LONG_UPDATE_INTERVAL, + update_interval=FIRMWARE_UPDATE_INTERVAL, ) if device.device and "wifi1" in device.device.features: coordinators[CONNECTED_WIFI_CLIENTS] = DataUpdateCoordinator( diff --git a/homeassistant/components/devolo_home_network/const.py b/homeassistant/components/devolo_home_network/const.py index ba3f5e5b815..aaee8051cb5 100644 --- a/homeassistant/components/devolo_home_network/const.py +++ b/homeassistant/components/devolo_home_network/const.py @@ -14,6 +14,7 @@ PRODUCT = "product" SERIAL_NUMBER = "serial_number" TITLE = "title" +FIRMWARE_UPDATE_INTERVAL = timedelta(hours=5) LONG_UPDATE_INTERVAL = timedelta(minutes=5) SHORT_UPDATE_INTERVAL = timedelta(seconds=15) diff --git a/homeassistant/components/devolo_home_network/update.py b/homeassistant/components/devolo_home_network/update.py index 21f6edd862c..1c95c4262b2 100644 --- a/homeassistant/components/devolo_home_network/update.py +++ b/homeassistant/components/devolo_home_network/update.py @@ -92,7 +92,6 @@ class DevoloUpdateEntity(DevoloCoordinatorEntity, UpdateEntity): """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 @@ class DevoloUpdateEntity(DevoloCoordinatorEntity, UpdateEntity): 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 29b25d0781b..c3705dad3dd 100644 --- a/homeassistant/components/dhcp/__init__.py +++ b/homeassistant/components/dhcp/__init__.py @@ -58,7 +58,6 @@ from homeassistant.helpers.event import ( 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 @@ class WatcherBase(ABC): 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 e65966fbaa2..3d9a5578045 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 ab892cd9324..32f696a04ce 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 3434b1dd84c..e035661db10 100644 --- a/homeassistant/components/discovergy/config_flow.py +++ b/homeassistant/components/discovergy/config_flow.py @@ -85,7 +85,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 d2548d0bacd..5f27c6a43d2 100644 --- a/homeassistant/components/discovergy/coordinator.py +++ b/homeassistant/components/discovergy/coordinator.py @@ -5,10 +5,9 @@ from datetime import timedelta 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 @@ _LOGGER = logging.getLogger(__name__) 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 @@ class DiscovergyUpdateCoordinator(DataUpdateCoordinator[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 ee7abb3e979..8c60d59fa6b 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 23c45b73ec5..e269d75e0f6 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.36.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 50877756d52..3a57ba2c8ce 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 @@ class DlnaDmrEntity(MediaPlayerEntity): "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 2adb2e76347..0d07eb0c042 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.36.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 c94fff1124e..ba97dbe38ec 100644 --- a/homeassistant/components/doods/image_processing.py +++ b/homeassistant/components/doods/image_processing.py @@ -392,6 +392,10 @@ class Doods(ImageProcessingEntity): 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/doods/manifest.json b/homeassistant/components/doods/manifest.json index bc7c7d97430..12397eb8990 100644 --- a/homeassistant/components/doods/manifest.json +++ b/homeassistant/components/doods/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/doods", "iot_class": "local_polling", "loggers": ["pydoods"], - "requirements": ["pydoods==1.0.2", "Pillow==10.0.0"] + "requirements": ["pydoods==1.0.2", "Pillow==10.0.1"] } diff --git a/homeassistant/components/doorbird/config_flow.py b/homeassistant/components/doorbird/config_flow.py index 56a02f49042..983e56e64da 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.components import zeroconf 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 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) -> 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/dormakaba_dkey/manifest.json b/homeassistant/components/dormakaba_dkey/manifest.json index 7a4f6b9d905..52e68b7521c 100644 --- a/homeassistant/components/dormakaba_dkey/manifest.json +++ b/homeassistant/components/dormakaba_dkey/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/dormakaba_dkey", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["py-dormakaba-dkey==1.0.4"] + "requirements": ["py-dormakaba-dkey==1.0.5"] } diff --git a/homeassistant/components/duotecno/__init__.py b/homeassistant/components/duotecno/__init__.py index 4c8060b468d..d9d890c28f3 100644 --- a/homeassistant/components/duotecno/__init__.py +++ b/homeassistant/components/duotecno/__init__.py @@ -11,7 +11,13 @@ from homeassistant.exceptions import ConfigEntryNotReady from .const import DOMAIN -PLATFORMS: list[Platform] = [Platform.SWITCH, Platform.COVER, Platform.LIGHT] +PLATFORMS: list[Platform] = [ + Platform.SWITCH, + Platform.COVER, + Platform.LIGHT, + Platform.CLIMATE, + Platform.BINARY_SENSOR, +] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/duotecno/binary_sensor.py b/homeassistant/components/duotecno/binary_sensor.py new file mode 100644 index 00000000000..a1638ce4055 --- /dev/null +++ b/homeassistant/components/duotecno/binary_sensor.py @@ -0,0 +1,34 @@ +"""Support for Duotecno binary sensors.""" + +from duotecno.unit import ControlUnit + +from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .entity import DuotecnoEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Duotecno binary sensor on config_entry.""" + cntrl = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + DuotecnoBinarySensor(channel) for channel in cntrl.get_units("ControlUnit") + ) + + +class DuotecnoBinarySensor(DuotecnoEntity, BinarySensorEntity): + """Representation of a DuotecnoBinarySensor.""" + + _unit: ControlUnit + + @property + def is_on(self) -> bool: + """Return true if the binary sensor is on.""" + return self._unit.is_on() diff --git a/homeassistant/components/duotecno/climate.py b/homeassistant/components/duotecno/climate.py new file mode 100644 index 00000000000..e7dfa53e53c --- /dev/null +++ b/homeassistant/components/duotecno/climate.py @@ -0,0 +1,92 @@ +"""Support for Duotecno climate devices.""" +from typing import Any, Final + +from duotecno.unit import SensUnit + +from homeassistant.components.climate import ( + ClimateEntity, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .entity import DuotecnoEntity, api_call + +HVACMODE: Final = { + 0: HVACMode.OFF, + 1: HVACMode.HEAT, + 2: HVACMode.COOL, +} +HVACMODE_REVERSE: Final = {value: key for key, value in HVACMODE.items()} + +PRESETMODES: Final = { + "sun": 0, + "half_sun": 1, + "moon": 2, + "half_moon": 3, +} +PRESETMODES_REVERSE: Final = {value: key for key, value in PRESETMODES.items()} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Duotecno climate based on config_entry.""" + cntrl = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + DuotecnoClimate(channel) for channel in cntrl.get_units(["SensUnit"]) + ) + + +class DuotecnoClimate(DuotecnoEntity, ClimateEntity): + """Representation of a Duotecno climate entity.""" + + _unit: SensUnit + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE + ) + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_hvac_modes = list(HVACMODE_REVERSE) + _attr_preset_modes = list(PRESETMODES) + _attr_translation_key = "duotecno" + + @property + def current_temperature(self) -> int | None: + """Get the current temperature.""" + return self._unit.get_cur_temp() + + @property + def target_temperature(self) -> float | None: + """Get the target temperature.""" + return self._unit.get_target_temp() + + @property + def hvac_mode(self) -> HVACMode: + """Get the current hvac_mode.""" + return HVACMODE[self._unit.get_state()] + + @property + def preset_mode(self) -> str: + """Get the preset mode.""" + return PRESETMODES_REVERSE[self._unit.get_preset()] + + @api_call + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperatures.""" + if (temp := kwargs.get(ATTR_TEMPERATURE)) is None: + return + await self._unit.set_temp(temp) + + @api_call + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set the preset mode.""" + await self._unit.set_preset(PRESETMODES[preset_mode]) + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Duotecno does not support setting this, we can only display it.""" diff --git a/homeassistant/components/duotecno/const.py b/homeassistant/components/duotecno/const.py index 114867b8d95..6bffe2358e1 100644 --- a/homeassistant/components/duotecno/const.py +++ b/homeassistant/components/duotecno/const.py @@ -1,3 +1,4 @@ """Constants for the duotecno integration.""" +from typing import Final -DOMAIN = "duotecno" +DOMAIN: Final = "duotecno" diff --git a/homeassistant/components/duotecno/manifest.json b/homeassistant/components/duotecno/manifest.json index d26d4fce61e..d04e883f867 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.9.0"] } diff --git a/homeassistant/components/duotecno/strings.json b/homeassistant/components/duotecno/strings.json index 379291eb626..a00647993a8 100644 --- a/homeassistant/components/duotecno/strings.json +++ b/homeassistant/components/duotecno/strings.json @@ -14,5 +14,21 @@ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "unknown": "[%key:common::config_flow::error::unknown%]" } + }, + "entity": { + "climate": { + "duotecno": { + "state_attributes": { + "preset_mode": { + "state": { + "sun": "Sun", + "half_sun": "Half sun", + "moon": "Moon", + "half_moon": "Half moon" + } + } + } + } + } } } diff --git a/homeassistant/components/dynalite/cover.py b/homeassistant/components/dynalite/cover.py index 96a1f41f9e3..2bac51e0b8b 100644 --- a/homeassistant/components/dynalite/cover.py +++ b/homeassistant/components/dynalite/cover.py @@ -12,6 +12,7 @@ from homeassistant.core import HomeAssistant, callback 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 @@ async def async_setup_entry( 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 43a4a5b106b..baf4c12a4c5 100644 --- a/homeassistant/components/dynalite/dynalitebase.py +++ b/homeassistant/components/dynalite/dynalitebase.py @@ -70,7 +70,7 @@ class DynaliteBase(RestoreEntity, ABC): ) 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/manifest.json b/homeassistant/components/ecobee/manifest.json index 43f22e2b4d6..71f5e04f75a 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 00000000000..205dfe67deb --- /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, Platform.NUMBER, Platform.SWITCH] + +_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 00000000000..0afc46c2370 --- /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 00000000000..8f8bbdcb45a --- /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 00000000000..b44ccc850ce --- /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 00000000000..901ed1bf4bf --- /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 00000000000..518f4d97a04 --- /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/number.py b/homeassistant/components/ecoforest/number.py new file mode 100644 index 00000000000..90ea0bd4dff --- /dev/null +++ b/homeassistant/components/ecoforest/number.py @@ -0,0 +1,74 @@ +"""Support for Ecoforest number platform.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from pyecoforest.models.device import Device + +from homeassistant.components.number import NumberEntity, NumberEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import EcoforestCoordinator +from .entity import EcoforestEntity + + +@dataclass +class EcoforestRequiredKeysMixin: + """Mixin for required keys.""" + + value_fn: Callable[[Device], float | None] + + +@dataclass +class EcoforestNumberEntityDescription( + NumberEntityDescription, EcoforestRequiredKeysMixin +): + """Describes an ecoforest number entity.""" + + +NUMBER_ENTITIES = ( + EcoforestNumberEntityDescription( + key="power_level", + translation_key="power_level", + native_min_value=1, + native_max_value=9, + native_step=1, + value_fn=lambda data: data.power, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Ecoforest number platform.""" + coordinator: EcoforestCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + entities = [ + EcoforestNumberEntity(coordinator, description) + for description in NUMBER_ENTITIES + ] + + async_add_entities(entities) + + +class EcoforestNumberEntity(EcoforestEntity, NumberEntity): + """Representation of an Ecoforest number entity.""" + + entity_description: EcoforestNumberEntityDescription + + @property + def native_value(self) -> float | None: + """Return the state of the entity.""" + return self.entity_description.value_fn(self.data) + + async def async_set_native_value(self, value: float) -> None: + """Update the native value.""" + await self.coordinator.api.set_power(int(value)) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/ecoforest/sensor.py b/homeassistant/components/ecoforest/sensor.py new file mode 100644 index 00000000000..91f3138af37 --- /dev/null +++ b/homeassistant/components/ecoforest/sensor.py @@ -0,0 +1,115 @@ +"""Support for Ecoforest sensors.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +import logging + +from pyecoforest.models.device import Alarm, Device, State + +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 homeassistant.helpers.typing import StateType + +from .const import DOMAIN +from .coordinator import EcoforestCoordinator +from .entity import EcoforestEntity + +_LOGGER = logging.getLogger(__name__) + +STATUS_TYPE = [s.value for s in State] +ALARM_TYPE = [a.value for a in Alarm] + ["none"] + + +@dataclass +class EcoforestRequiredKeysMixin: + """Mixin for required keys.""" + + value_fn: Callable[[Device], StateType] + + +@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, + ), + EcoforestSensorEntityDescription( + key="cpu_temperature", + translation_key="cpu_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + entity_registry_enabled_default=False, + value_fn=lambda data: data.cpu_temperature, + ), + EcoforestSensorEntityDescription( + key="gas_temperature", + translation_key="gas_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + entity_registry_enabled_default=False, + value_fn=lambda data: data.gas_temperature, + ), + EcoforestSensorEntityDescription( + key="ntc_temperature", + translation_key="ntc_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + entity_registry_enabled_default=False, + value_fn=lambda data: data.ntc_temperature, + ), + EcoforestSensorEntityDescription( + key="status", + translation_key="status", + device_class=SensorDeviceClass.ENUM, + options=STATUS_TYPE, + value_fn=lambda data: data.state.value, + ), + EcoforestSensorEntityDescription( + key="alarm", + translation_key="alarm", + device_class=SensorDeviceClass.ENUM, + options=ALARM_TYPE, + icon="mdi:alert", + value_fn=lambda data: data.alarm.value if data.alarm else "none", + ), +) + + +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) -> StateType: + """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 00000000000..bd0605eab82 --- /dev/null +++ b/homeassistant/components/ecoforest/strings.json @@ -0,0 +1,61 @@ +{ + "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%]" + } + }, + "entity": { + "sensor": { + "cpu_temperature": { + "name": "CPU temperature" + }, + "gas_temperature": { + "name": "Gas temperature" + }, + "ntc_temperature": { + "name": "NTC probe temperature" + }, + "status": { + "name": "Status", + "state": { + "off": "[%key:common::state::off%]", + "starting": "Starting", + "pre_heating": "Pre-heating", + "on": "[%key:common::state::on%]", + "shutting_down": "Shutting down", + "stand_by": "[%key:common::state::standby%]", + "alarm": "Alarm" + } + }, + "alarm": { + "name": "Alarm", + "state": { + "air_depression": "Air depression", + "pellets": "Pellets", + "cpu_overheating": "CPU overheating", + "unkownn": "Unknown alarm", + "none": "None" + } + } + }, + "number": { + "power_level": { + "name": "Power level" + } + } + } +} diff --git a/homeassistant/components/ecoforest/switch.py b/homeassistant/components/ecoforest/switch.py new file mode 100644 index 00000000000..32341ff5d61 --- /dev/null +++ b/homeassistant/components/ecoforest/switch.py @@ -0,0 +1,78 @@ +"""Switch platform for Ecoforest.""" +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass + +from pyecoforest.api import EcoforestApi +from pyecoforest.models.device import Device + +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 .const import DOMAIN +from .coordinator import EcoforestCoordinator +from .entity import EcoforestEntity + + +@dataclass +class EcoforestSwitchRequiredKeysMixin: + """Mixin for required keys.""" + + value_fn: Callable[[Device], bool] + switch_fn: Callable[[EcoforestApi, bool], Awaitable[Device]] + + +@dataclass +class EcoforestSwitchEntityDescription( + SwitchEntityDescription, EcoforestSwitchRequiredKeysMixin +): + """Describes an Ecoforest switch entity.""" + + +SWITCH_TYPES: tuple[EcoforestSwitchEntityDescription, ...] = ( + EcoforestSwitchEntityDescription( + key="status", + name=None, + value_fn=lambda data: data.on, + switch_fn=lambda api, status: api.turn(status), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Ecoforest switch platform.""" + coordinator: EcoforestCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + entities = [ + EcoforestSwitchEntity(coordinator, description) for description in SWITCH_TYPES + ] + + async_add_entities(entities) + + +class EcoforestSwitchEntity(EcoforestEntity, SwitchEntity): + """Representation of an Ecoforest switch entity.""" + + entity_description: EcoforestSwitchEntityDescription + + @property + def is_on(self) -> bool: + """Return the state of the ecoforest device.""" + return self.entity_description.value_fn(self.data) + + async def async_turn_on(self): + """Turn on the ecoforest device.""" + await self.entity_description.switch_fn(self.coordinator.api, True) + await self.coordinator.async_request_refresh() + + async def async_turn_off(self): + """Turn off the ecoforest device.""" + await self.entity_description.switch_fn(self.coordinator.api, False) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/econet/__init__.py b/homeassistant/components/econet/__init__.py index 3005993bf99..36cdeb68821 100644 --- a/homeassistant/components/econet/__init__.py +++ b/homeassistant/components/econet/__init__.py @@ -13,7 +13,7 @@ from pyeconet.errors import ( ) 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 @@ class EcoNetEntity(Entity): 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 7233d135f2e..e77c4face74 100644 --- a/homeassistant/components/econet/climate.py +++ b/homeassistant/components/econet/climate.py @@ -16,7 +16,7 @@ from homeassistant.components.climate import ( 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 @@ class EcoNetThermostat(EcoNetEntity, ClimateEntity): """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 c94afd8b5d7..cbaf4551d03 100644 --- a/homeassistant/components/econet/water_heater.py +++ b/homeassistant/components/econet/water_heater.py @@ -16,7 +16,7 @@ from homeassistant.components.water_heater import ( 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/select.py b/homeassistant/components/electric_kiwi/select.py index 9d883c72d1e..eb8aaac8c2f 100644 --- a/homeassistant/components/electric_kiwi/select.py +++ b/homeassistant/components/electric_kiwi/select.py @@ -50,7 +50,10 @@ class ElectricKiwiSelectHOPEntity( ) -> 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 @@ class ElectricKiwiSelectHOPEntity( @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 8c983b92dd5..8017bbf006e 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 @@ class ElectricKiwiHOPEntity( """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 14046b7079b..b78157588e8 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 @@ from .discovery import ( async_trigger_discovery, async_update_entry_from_discovery, ) +from .models import ELKM1Data SYNC_TIMEOUT = 120 @@ -303,14 +305,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 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 _create_elk_services(hass: HomeAssistant) -> 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 @@ class ElkEntity(Entity): 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 diff --git a/homeassistant/components/elkm1/alarm_control_panel.py b/homeassistant/components/elkm1/alarm_control_panel.py index 3f5163a849d..bfac466caeb 100644 --- a/homeassistant/components/elkm1/alarm_control_panel.py +++ b/homeassistant/components/elkm1/alarm_control_panel.py @@ -40,6 +40,7 @@ from .const import ( 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 38a72796482..95f9162468e 100644 --- a/homeassistant/components/elkm1/binary_sensor.py +++ b/homeassistant/components/elkm1/binary_sensor.py @@ -14,6 +14,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback 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 1ece7a7758a..c1e6dc7b034 100644 --- a/homeassistant/components/elkm1/climate.py +++ b/homeassistant/components/elkm1/climate.py @@ -23,6 +23,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback 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 3db457761aa..844e4f3dd15 100644 --- a/homeassistant/components/elkm1/light.py +++ b/homeassistant/components/elkm1/light.py @@ -14,6 +14,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback 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 ccac1593fa0..3ec5be46d41 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 00000000000..9f784951c11 --- /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 1869e5ba0f3..9cb0c62ff77 100644 --- a/homeassistant/components/elkm1/scene.py +++ b/homeassistant/components/elkm1/scene.py @@ -12,6 +12,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback 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 0de97a1710e..9bd78f61673 100644 --- a/homeassistant/components/elkm1/sensor.py +++ b/homeassistant/components/elkm1/sensor.py @@ -23,6 +23,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback 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 a17557b1507..b4080adc698 100644 --- a/homeassistant/components/elkm1/switch.py +++ b/homeassistant/components/elkm1/switch.py @@ -12,6 +12,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback 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 01dae2dca77..ff3591e0066 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 d39d530eccc..843aeddde7b 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.1"] + "requirements": ["sense-energy==0.12.2"] } diff --git a/homeassistant/components/enmax/__init__.py b/homeassistant/components/enmax/__init__.py new file mode 100644 index 00000000000..21ca8ab1c58 --- /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 00000000000..2c2be413824 --- /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 b41d29626e7..999542ee2a5 100644 --- a/homeassistant/components/enphase_envoy/config_flow.py +++ b/homeassistant/components/enphase_envoy/config_flow.py @@ -15,7 +15,6 @@ from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNA 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 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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/envisalink/__init__.py b/homeassistant/components/envisalink/__init__.py index 55ad58a030d..b0a4619bbf9 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/esphome/bluetooth/device.py b/homeassistant/components/esphome/bluetooth/device.py index 8d060151dbf..c76562a2145 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 @@ class ESPHomeBluetoothDevice: """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/manager.py b/homeassistant/components/esphome/manager.py index ee0d2371a56..dfd7376f4f4 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -16,6 +16,7 @@ from aioesphomeapi import ( RequiresEncryptionAPIError, UserService, UserServiceArgType, + VoiceAssistantAudioSettings, VoiceAssistantEventType, ) from awesomeversion import AwesomeVersion @@ -319,27 +320,31 @@ class ESPHomeManager: self.voice_assistant_udp_server = None async def _handle_pipeline_start( - self, conversation_id: str, flags: int + self, + conversation_id: str, + flags: int, + audio_settings: VoiceAssistantAudioSettings, ) -> int | None: """Start a voice assistant pipeline.""" if self.voice_assistant_udp_server is not None: return None hass = self.hass - voice_assistant_udp_server = VoiceAssistantUDPServer( + self.voice_assistant_udp_server = VoiceAssistantUDPServer( hass, self.entry_data, self._handle_pipeline_event, self._handle_pipeline_finished, ) - port = await voice_assistant_udp_server.start_server() + port = await self.voice_assistant_udp_server.start_server() assert self.device_id is not None, "Device ID must be set" hass.async_create_background_task( - voice_assistant_udp_server.run_pipeline( + self.voice_assistant_udp_server.run_pipeline( device_id=self.device_id, conversation_id=conversation_id or None, flags=flags, + audio_settings=audio_settings, ), "esphome.voice_assistant_udp_server.run_pipeline", ) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 1c8da971168..8169eeb70e3 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -2,7 +2,7 @@ "domain": "esphome", "name": "ESPHome", "after_dependencies": ["zeroconf", "tag"], - "codeowners": ["@OttoWinter", "@jesserockz", "@bdraco"], + "codeowners": ["@OttoWinter", "@jesserockz", "@kbx81", "@bdraco"], "config_flow": true, "dependencies": ["assist_pipeline", "bluetooth"], "dhcp": [ @@ -15,9 +15,9 @@ "iot_class": "local_push", "loggers": ["aioesphomeapi", "noiseprotocol"], "requirements": [ - "async_interrupt==1.1.1", - "aioesphomeapi==16.0.5", - "bluetooth-data-tools==1.11.0", + "async-interrupt==1.1.1", + "aioesphomeapi==17.0.1", + "bluetooth-data-tools==1.12.0", "esphome-dashboard-api==1.2.3" ], "zeroconf": ["_esphomelib._tcp.local."] diff --git a/homeassistant/components/esphome/voice_assistant.py b/homeassistant/components/esphome/voice_assistant.py index c501d756e54..8fba4bfb39a 100644 --- a/homeassistant/components/esphome/voice_assistant.py +++ b/homeassistant/components/esphome/voice_assistant.py @@ -7,18 +7,27 @@ import logging import socket from typing import cast -from aioesphomeapi import VoiceAssistantCommandFlag, VoiceAssistantEventType +from aioesphomeapi import ( + VoiceAssistantAudioSettings, + VoiceAssistantCommandFlag, + VoiceAssistantEventType, +) from homeassistant.components import stt, tts from homeassistant.components.assist_pipeline import ( + AudioSettings, PipelineEvent, PipelineEventType, PipelineNotFound, PipelineStage, + WakeWordSettings, async_pipeline_from_audio_stream, select as pipeline_select, ) -from homeassistant.components.assist_pipeline.error import WakeWordDetectionError +from homeassistant.components.assist_pipeline.error import ( + WakeWordDetectionAborted, + WakeWordDetectionError, +) from homeassistant.components.media_player import async_process_play_media_url from homeassistant.core import Context, HomeAssistant, callback @@ -64,7 +73,6 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): entry_data: RuntimeEntryData, handle_event: Callable[[VoiceAssistantEventType, dict[str, str] | None], None], handle_finished: Callable[[], None], - audio_timeout: float = 2.0, ) -> None: """Initialize UDP receiver.""" self.context = Context() @@ -78,7 +86,6 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): self.handle_event = handle_event self.handle_finished = handle_finished self._tts_done = asyncio.Event() - self.audio_timeout = audio_timeout async def start_server(self) -> int: """Start accepting connections.""" @@ -212,9 +219,11 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): device_id: str, conversation_id: str | None, flags: int = 0, - pipeline_timeout: float = 30.0, + audio_settings: VoiceAssistantAudioSettings | None = None, ) -> None: """Run the Voice Assistant pipeline.""" + if audio_settings is None or audio_settings.volume_multiplier == 0: + audio_settings = VoiceAssistantAudioSettings() tts_audio_output = ( "raw" if self.device_info.voice_assistant_version >= 2 else "mp3" @@ -226,31 +235,37 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): else: start_stage = PipelineStage.STT try: - async with asyncio.timeout(pipeline_timeout): - await async_pipeline_from_audio_stream( - self.hass, - context=self.context, - event_callback=self._event_callback, - stt_metadata=stt.SpeechMetadata( - language="", # set in async_pipeline_from_audio_stream - format=stt.AudioFormats.WAV, - codec=stt.AudioCodecs.PCM, - bit_rate=stt.AudioBitRates.BITRATE_16, - sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, - channel=stt.AudioChannels.CHANNEL_MONO, - ), - stt_stream=self._iterate_packets(), - pipeline_id=pipeline_select.get_chosen_pipeline( - self.hass, DOMAIN, self.device_info.mac_address - ), - conversation_id=conversation_id, - device_id=device_id, - tts_audio_output=tts_audio_output, - start_stage=start_stage, - ) + await async_pipeline_from_audio_stream( + self.hass, + context=self.context, + event_callback=self._event_callback, + stt_metadata=stt.SpeechMetadata( + language="", # set in async_pipeline_from_audio_stream + format=stt.AudioFormats.WAV, + codec=stt.AudioCodecs.PCM, + bit_rate=stt.AudioBitRates.BITRATE_16, + sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, + channel=stt.AudioChannels.CHANNEL_MONO, + ), + stt_stream=self._iterate_packets(), + pipeline_id=pipeline_select.get_chosen_pipeline( + self.hass, DOMAIN, self.device_info.mac_address + ), + conversation_id=conversation_id, + device_id=device_id, + tts_audio_output=tts_audio_output, + start_stage=start_stage, + wake_word_settings=WakeWordSettings(timeout=5), + audio_settings=AudioSettings( + noise_suppression_level=audio_settings.noise_suppression_level, + auto_gain_dbfs=audio_settings.auto_gain, + volume_multiplier=audio_settings.volume_multiplier, + is_vad_enabled=bool(flags & VoiceAssistantCommandFlag.USE_VAD), + ), + ) - # Block until TTS is done sending - await self._tts_done.wait() + # Block until TTS is done sending + await self._tts_done.wait() _LOGGER.debug("Pipeline finished") except PipelineNotFound: @@ -262,6 +277,8 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): }, ) _LOGGER.warning("Pipeline not found") + except WakeWordDetectionAborted: + pass # Wake word detection was aborted and `handle_finished` is enough. except WakeWordDetectionError as e: self.handle_event( VoiceAssistantEventType.VOICE_ASSISTANT_ERROR, @@ -270,19 +287,6 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): "message": e.message, }, ) - _LOGGER.warning("No Wake word provider found") - except asyncio.TimeoutError: - if self.stopped: - # The pipeline was stopped gracefully - return - self.handle_event( - VoiceAssistantEventType.VOICE_ASSISTANT_ERROR, - { - "code": "pipeline-timeout", - "message": "Pipeline timeout", - }, - ) - _LOGGER.warning("Pipeline timeout") finally: self.handle_finished() diff --git a/homeassistant/components/event/__init__.py b/homeassistant/components/event/__init__.py index f6ba2d79bfe..d9608670972 100644 --- a/homeassistant/components/event/__init__.py +++ b/homeassistant/components/event/__init__.py @@ -105,6 +105,8 @@ class EventExtraStoredData(ExtraStoredData): 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 759fd80bcf0..00000000000 --- 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 b165492d076..3606da33499 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/binary_sensor.py b/homeassistant/components/faa_delays/binary_sensor.py index bc09a604cd6..5cbb206f223 100644 --- a/homeassistant/components/faa_delays/binary_sensor.py +++ b/homeassistant/components/faa_delays/binary_sensor.py @@ -12,7 +12,35 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN, FAA_BINARY_SENSORS +from .const import DOMAIN + +FAA_BINARY_SENSORS: tuple[BinarySensorEntityDescription, ...] = ( + BinarySensorEntityDescription( + key="GROUND_DELAY", + name="Ground Delay", + icon="mdi:airport", + ), + BinarySensorEntityDescription( + key="GROUND_STOP", + name="Ground Stop", + icon="mdi:airport", + ), + BinarySensorEntityDescription( + key="DEPART_DELAY", + name="Departure Delay", + icon="mdi:airplane-takeoff", + ), + BinarySensorEntityDescription( + key="ARRIVE_DELAY", + name="Arrival Delay", + icon="mdi:airplane-landing", + ), + BinarySensorEntityDescription( + key="CLOSURE", + name="Closure", + icon="mdi:airplane:off", + ), +) async def async_setup_entry( @@ -42,7 +70,7 @@ class FAABinarySensor(CoordinatorEntity, BinarySensorEntity): self.coordinator = coordinator self._entry_id = entry_id self._attrs: dict[str, Any] = {} - _id = coordinator.data.iata + _id = coordinator.data.code self._attr_name = f"{_id} {description.name}" self._attr_unique_id = f"{_id}_{description.key}" @@ -83,7 +111,6 @@ class FAABinarySensor(CoordinatorEntity, BinarySensorEntity): self._attrs["trend"] = self.coordinator.data.arrive_delay.trend self._attrs["reason"] = self.coordinator.data.arrive_delay.reason elif sensor_type == "CLOSURE": - self._attrs["begin"] = self.coordinator.data.closure.begin + self._attrs["begin"] = self.coordinator.data.closure.start self._attrs["end"] = self.coordinator.data.closure.end - self._attrs["reason"] = self.coordinator.data.closure.reason return self._attrs diff --git a/homeassistant/components/faa_delays/config_flow.py b/homeassistant/components/faa_delays/config_flow.py index 023fe4d6a5b..b2f7f69dd49 100644 --- a/homeassistant/components/faa_delays/config_flow.py +++ b/homeassistant/components/faa_delays/config_flow.py @@ -35,10 +35,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): try: await data.update() - except faadelays.InvalidAirport: - _LOGGER.error("Airport code %s is invalid", user_input[CONF_ID]) - errors[CONF_ID] = "invalid_airport" - except ClientConnectionError: _LOGGER.error("Error connecting to FAA API") errors["base"] = "cannot_connect" @@ -49,11 +45,10 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if not errors: _LOGGER.debug( - "Creating entry with id: %s, name: %s", + "Creating entry with id: %s", user_input[CONF_ID], - data.name, ) - return self.async_create_entry(title=data.name, data=user_input) + return self.async_create_entry(title=data.code, data=user_input) return self.async_show_form( step_id="user", data_schema=DATA_SCHEMA, errors=errors diff --git a/homeassistant/components/faa_delays/const.py b/homeassistant/components/faa_delays/const.py index f7ee8e7bad8..3b9bda33bfb 100644 --- a/homeassistant/components/faa_delays/const.py +++ b/homeassistant/components/faa_delays/const.py @@ -1,34 +1,4 @@ """Constants for the FAA Delays integration.""" from __future__ import annotations -from homeassistant.components.binary_sensor import BinarySensorEntityDescription - DOMAIN = "faa_delays" - -FAA_BINARY_SENSORS: tuple[BinarySensorEntityDescription, ...] = ( - BinarySensorEntityDescription( - key="GROUND_DELAY", - name="Ground Delay", - icon="mdi:airport", - ), - BinarySensorEntityDescription( - key="GROUND_STOP", - name="Ground Stop", - icon="mdi:airport", - ), - BinarySensorEntityDescription( - key="DEPART_DELAY", - name="Departure Delay", - icon="mdi:airplane-takeoff", - ), - BinarySensorEntityDescription( - key="ARRIVE_DELAY", - name="Arrival Delay", - icon="mdi:airplane-landing", - ), - BinarySensorEntityDescription( - key="CLOSURE", - name="Closure", - icon="mdi:airplane:off", - ), -) diff --git a/homeassistant/components/faa_delays/coordinator.py b/homeassistant/components/faa_delays/coordinator.py new file mode 100644 index 00000000000..f2aefdada66 --- /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/faa_delays/manifest.json b/homeassistant/components/faa_delays/manifest.json index 8fb07d1e187..07c2cfea771 100644 --- a/homeassistant/components/faa_delays/manifest.json +++ b/homeassistant/components/faa_delays/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/faa_delays", "iot_class": "cloud_polling", "loggers": ["faadelays"], - "requirements": ["faadelays==0.0.7"] + "requirements": ["faadelays==2023.9.1"] } diff --git a/homeassistant/components/fan/__init__.py b/homeassistant/components/fan/__init__.py index 6aa29d8b804..a149909e029 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 e7305b64f16..00000000000 --- 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 86f25253c2d..0af6cf02586 100644 --- a/homeassistant/components/fibaro/__init__.py +++ b/homeassistant/components/fibaro/__init__.py @@ -8,6 +8,7 @@ from typing import Any from pyfibaro.fibaro_client import FibaroClient from pyfibaro.fibaro_device import DeviceModel +from pyfibaro.fibaro_scene import SceneModel from requests.exceptions import HTTPError from homeassistant.config_entries import ConfigEntry @@ -35,8 +36,6 @@ from .const import CONF_IMPORT_PLUGINS, DOMAIN _LOGGER = logging.getLogger(__name__) -FIBARO_CONTROLLER = "fibaro_controller" -FIBARO_DEVICES = "fibaro_devices" PLATFORMS = [ Platform.BINARY_SENSOR, Platform.CLIMATE, @@ -89,9 +88,11 @@ class FibaroController: self._import_plugins = config[CONF_IMPORT_PLUGINS] self._room_map = None # Mapping roomId to room object self._device_map = None # Mapping deviceId to device object - self.fibaro_devices: dict[Platform, list] = defaultdict( + self.fibaro_devices: dict[Platform, list[DeviceModel]] = defaultdict( list ) # List of devices by entity platform + # All scenes + self._scenes: list[SceneModel] = [] self._callbacks: dict[Any, Any] = {} # Update value callbacks by deviceId self.hub_serial: str # Unique serial number of the hub self.hub_name: str # The friendly name of the hub @@ -117,7 +118,7 @@ class FibaroController: self._room_map = {room.fibaro_id: room for room in self._client.read_rooms()} self._read_devices() - self._read_scenes() + self._scenes = self._client.read_scenes() return True def connect_with_error_handling(self) -> None: @@ -284,12 +285,9 @@ class FibaroController: room = self._room_map.get(room_id) return room.name if room else None - def _read_scenes(self): - scenes = self._client.read_scenes() - for device in scenes: - device.fibaro_controller = self - self.fibaro_devices[Platform.SCENE].append(device) - _LOGGER.debug("Scene -> %s", device) + def read_scenes(self) -> list[SceneModel]: + """Return list of scenes.""" + return self._scenes def _read_devices(self): """Read and process the device list.""" @@ -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 @@ -521,7 +514,7 @@ class FibaroDevice(Entity): def update(self) -> None: """Update the available state of the entity.""" - if isinstance(self.fibaro_device, DeviceModel) and self.fibaro_device.has_dead: + if self.fibaro_device.has_dead: self._attr_available = not self.fibaro_device.dead diff --git a/homeassistant/components/fibaro/binary_sensor.py b/homeassistant/components/fibaro/binary_sensor.py index 57b3bc99b4f..07c0d9a779c 100644 --- a/homeassistant/components/fibaro/binary_sensor.py +++ b/homeassistant/components/fibaro/binary_sensor.py @@ -16,7 +16,7 @@ from homeassistant.const import Platform 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 a56056ade03..18fef8dbe7a 100644 --- a/homeassistant/components/fibaro/climate.py +++ b/homeassistant/components/fibaro/climate.py @@ -21,7 +21,7 @@ from homeassistant.const import ATTR_TEMPERATURE, Platform, UnitOfTemperature 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 c73c45d254c..d353b352c5c 100644 --- a/homeassistant/components/fibaro/cover.py +++ b/homeassistant/components/fibaro/cover.py @@ -17,7 +17,7 @@ from homeassistant.const import Platform 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 6a918f64f86..981b81fdd43 100644 --- a/homeassistant/components/fibaro/light.py +++ b/homeassistant/components/fibaro/light.py @@ -23,7 +23,7 @@ from homeassistant.const import Platform 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 503407bc28f..715116d2843 100644 --- a/homeassistant/components/fibaro/lock.py +++ b/homeassistant/components/fibaro/lock.py @@ -11,7 +11,7 @@ from homeassistant.const import Platform 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 812a85b2f50..7ae8bff151f 100644 --- a/homeassistant/components/fibaro/scene.py +++ b/homeassistant/components/fibaro/scene.py @@ -7,13 +7,12 @@ from pyfibaro.fibaro_scene import SceneModel from homeassistant.components.scene import Scene from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import slugify -from . import FIBARO_DEVICES, FibaroController +from . import FibaroController from .const import DOMAIN @@ -23,13 +22,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, controller) for scene in controller.read_scenes()], True, ) @@ -37,11 +32,10 @@ async def async_setup_entry( class FibaroScene(Scene): """Representation of a Fibaro scene entity.""" - def __init__(self, fibaro_scene: SceneModel) -> None: + def __init__(self, fibaro_scene: SceneModel, controller: FibaroController) -> None: """Initialize the Fibaro scene.""" self._fibaro_scene = fibaro_scene - controller: FibaroController = fibaro_scene.fibaro_controller room_name = controller.get_room_name(fibaro_scene.room_id) if not room_name: room_name = "Unknown" diff --git a/homeassistant/components/fibaro/sensor.py b/homeassistant/components/fibaro/sensor.py index c41c4afe312..e859a9b1afb 100644 --- a/homeassistant/components/fibaro/sensor.py +++ b/homeassistant/components/fibaro/sensor.py @@ -26,7 +26,7 @@ from homeassistant.core import HomeAssistant 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,14 +107,24 @@ 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 entities.append(FibaroSensor(device, entity_description)) - for platform in (Platform.COVER, Platform.LIGHT, Platform.SENSOR, Platform.SWITCH): - for device in hass.data[DOMAIN][entry.entry_id][FIBARO_DEVICES][platform]: + for platform in ( + Platform.BINARY_SENSOR, + Platform.CLIMATE, + Platform.COVER, + Platform.LIGHT, + Platform.LOCK, + Platform.SENSOR, + Platform.SWITCH, + ): + 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 6ca770ab2d1..fdd473ea282 100644 --- a/homeassistant/components/fibaro/switch.py +++ b/homeassistant/components/fibaro/switch.py @@ -11,7 +11,7 @@ from homeassistant.const import Platform 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/fitbit/api.py b/homeassistant/components/fitbit/api.py new file mode 100644 index 00000000000..bf287471292 --- /dev/null +++ b/homeassistant/components/fitbit/api.py @@ -0,0 +1,106 @@ +"""API for fitbit bound to Home Assistant OAuth.""" + +import logging +from typing import Any, cast + +from fitbit import Fitbit + +from homeassistant.core import HomeAssistant +from homeassistant.util.unit_system import METRIC_SYSTEM + +from .const import FitbitUnitSystem +from .model import FitbitDevice, FitbitProfile + +_LOGGER = logging.getLogger(__name__) + + +class FitbitApi: + """Fitbit client library wrapper base class.""" + + def __init__( + self, + hass: HomeAssistant, + client: Fitbit, + unit_system: FitbitUnitSystem | None = None, + ) -> None: + """Initialize Fitbit auth.""" + self._hass = hass + self._profile: FitbitProfile | None = None + self._client = client + self._unit_system = unit_system + + @property + def client(self) -> Fitbit: + """Property to expose the underlying client library.""" + return self._client + + async def async_get_user_profile(self) -> FitbitProfile: + """Return the user profile from the API.""" + if self._profile is None: + response: dict[str, Any] = await self._hass.async_add_executor_job( + self._client.user_profile_get + ) + _LOGGER.debug("user_profile_get=%s", response) + profile = response["user"] + self._profile = FitbitProfile( + encoded_id=profile["encodedId"], + full_name=profile["fullName"], + locale=profile.get("locale"), + ) + return self._profile + + async def async_get_unit_system(self) -> FitbitUnitSystem: + """Get the unit system to use when fetching timeseries. + + This is used in a couple ways. The first is to determine the request + header to use when talking to the fitbit API which changes the + units returned by the API. The second is to tell Home Assistant the + units set in sensor values for the values returned by the API. + """ + if ( + self._unit_system is not None + and self._unit_system != FitbitUnitSystem.LEGACY_DEFAULT + ): + return self._unit_system + # Use units consistent with the account user profile or fallback to the + # home assistant unit settings. + profile = await self.async_get_user_profile() + if profile.locale == FitbitUnitSystem.EN_GB: + return FitbitUnitSystem.EN_GB + if self._hass.config.units is METRIC_SYSTEM: + return FitbitUnitSystem.METRIC + return FitbitUnitSystem.EN_US + + async def async_get_devices(self) -> list[FitbitDevice]: + """Return available devices.""" + devices: list[dict[str, str]] = await self._hass.async_add_executor_job( + self._client.get_devices + ) + _LOGGER.debug("get_devices=%s", devices) + return [ + FitbitDevice( + id=device["id"], + device_version=device["deviceVersion"], + battery_level=int(device["batteryLevel"]), + battery=device["battery"], + type=device["type"], + ) + for device in devices + ] + + async def async_get_latest_time_series(self, resource_type: str) -> dict[str, Any]: + """Return the most recent value from the time series for the specified resource type.""" + + # Set request header based on the configured unit system + self._client.system = await self.async_get_unit_system() + + def _time_series() -> dict[str, Any]: + return cast( + dict[str, Any], self._client.time_series(resource_type, period="7d") + ) + + response: dict[str, Any] = await self._hass.async_add_executor_job(_time_series) + _LOGGER.debug("time_series(%s)=%s", resource_type, response) + key = resource_type.replace("/", "-") + dated_results: list[dict[str, Any]] = response[key] + return dated_results[-1] diff --git a/homeassistant/components/fitbit/const.py b/homeassistant/components/fitbit/const.py index 1578359356d..19734add07a 100644 --- a/homeassistant/components/fitbit/const.py +++ b/homeassistant/components/fitbit/const.py @@ -1,16 +1,12 @@ """Constants for the Fitbit platform.""" from __future__ import annotations +from enum import StrEnum from typing import Final -from homeassistant.const import ( - CONF_CLIENT_ID, - CONF_CLIENT_SECRET, - UnitOfLength, - UnitOfMass, - UnitOfTime, - UnitOfVolume, -) +from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET + +DOMAIN: Final = "fitbit" ATTR_ACCESS_TOKEN: Final = "access_token" ATTR_REFRESH_TOKEN: Final = "refresh_token" @@ -41,46 +37,31 @@ DEFAULT_CONFIG: Final[dict[str, str]] = { } DEFAULT_CLOCK_FORMAT: Final = "24H" - -FITBIT_MEASUREMENTS: Final[dict[str, dict[str, str]]] = { - "en_US": { - ATTR_DURATION: UnitOfTime.MILLISECONDS, - ATTR_DISTANCE: UnitOfLength.MILES, - ATTR_ELEVATION: UnitOfLength.FEET, - ATTR_HEIGHT: UnitOfLength.INCHES, - ATTR_WEIGHT: UnitOfMass.POUNDS, - ATTR_BODY: UnitOfLength.INCHES, - ATTR_LIQUIDS: UnitOfVolume.FLUID_OUNCES, - ATTR_BLOOD_GLUCOSE: f"{UnitOfMass.MILLIGRAMS}/dL", - ATTR_BATTERY: "", - }, - "en_GB": { - ATTR_DURATION: UnitOfTime.MILLISECONDS, - ATTR_DISTANCE: UnitOfLength.KILOMETERS, - ATTR_ELEVATION: UnitOfLength.METERS, - ATTR_HEIGHT: UnitOfLength.CENTIMETERS, - ATTR_WEIGHT: UnitOfMass.STONES, - ATTR_BODY: UnitOfLength.CENTIMETERS, - ATTR_LIQUIDS: UnitOfVolume.MILLILITERS, - ATTR_BLOOD_GLUCOSE: "mmol/L", - ATTR_BATTERY: "", - }, - "metric": { - ATTR_DURATION: UnitOfTime.MILLISECONDS, - ATTR_DISTANCE: UnitOfLength.KILOMETERS, - ATTR_ELEVATION: UnitOfLength.METERS, - ATTR_HEIGHT: UnitOfLength.CENTIMETERS, - ATTR_WEIGHT: UnitOfMass.KILOGRAMS, - ATTR_BODY: UnitOfLength.CENTIMETERS, - ATTR_LIQUIDS: UnitOfVolume.MILLILITERS, - ATTR_BLOOD_GLUCOSE: "mmol/L", - ATTR_BATTERY: "", - }, -} - BATTERY_LEVELS: Final[dict[str, int]] = { "High": 100, "Medium": 50, "Low": 20, "Empty": 0, } + + +class FitbitUnitSystem(StrEnum): + """Fitbit unit system set when sending requests to the Fitbit API. + + This is used as a header to tell the Fitbit API which type of units to return. + https://dev.fitbit.com/build/reference/web-api/developer-guide/application-design/#Units + + Prefer to leave unset for newer configurations to use the Home Assistant default units. + """ + + LEGACY_DEFAULT = "default" + """When set, will use an appropriate default using a legacy algorithm.""" + + METRIC = "metric" + """Use metric units.""" + + EN_US = "en_US" + """Use United States units.""" + + EN_GB = "en_GB" + """Use United Kingdom units.""" diff --git a/homeassistant/components/fitbit/manifest.json b/homeassistant/components/fitbit/manifest.json index 510489a197b..510fe8da900 100644 --- a/homeassistant/components/fitbit/manifest.json +++ b/homeassistant/components/fitbit/manifest.json @@ -1,7 +1,7 @@ { "domain": "fitbit", "name": "Fitbit", - "codeowners": [], + "codeowners": ["@allenporter"], "dependencies": ["configurator", "http"], "documentation": "https://www.home-assistant.io/integrations/fitbit", "iot_class": "cloud_polling", diff --git a/homeassistant/components/fitbit/model.py b/homeassistant/components/fitbit/model.py new file mode 100644 index 00000000000..3d321d8dd01 --- /dev/null +++ b/homeassistant/components/fitbit/model.py @@ -0,0 +1,37 @@ +"""Data representation for fitbit API responses.""" + +from dataclasses import dataclass + + +@dataclass +class FitbitProfile: + """User profile from the Fitbit API response.""" + + encoded_id: str + """The ID representing the Fitbit user.""" + + full_name: str + """The first name value specified in the user's account settings.""" + + locale: str | None + """The locale defined in the user's Fitbit account settings.""" + + +@dataclass +class FitbitDevice: + """Device from the Fitbit API response.""" + + id: str + """The device ID.""" + + device_version: str + """The product name of the device.""" + + battery_level: int + """The battery level as a percentage.""" + + battery: str + """Returns the battery level of the device.""" + + type: str + """The type of the device such as TRACKER or SCALE.""" diff --git a/homeassistant/components/fitbit/sensor.py b/homeassistant/components/fitbit/sensor.py index 6c93fbe35c1..e08f56e0e34 100644 --- a/homeassistant/components/fitbit/sensor.py +++ b/homeassistant/components/fitbit/sensor.py @@ -1,6 +1,8 @@ """Support for the Fitbit API.""" from __future__ import annotations +import asyncio +from collections.abc import Callable from dataclasses import dataclass import datetime import logging @@ -28,6 +30,8 @@ from homeassistant.const import ( CONF_CLIENT_SECRET, CONF_UNIT_SYSTEM, PERCENTAGE, + UnitOfLength, + UnitOfMass, UnitOfTime, ) from homeassistant.core import HomeAssistant @@ -38,8 +42,8 @@ from homeassistant.helpers.json import save_json from homeassistant.helpers.network import NoURLAvailableError, get_url from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.json import load_json_object -from homeassistant.util.unit_system import METRIC_SYSTEM +from .api import FitbitApi from .const import ( ATTR_ACCESS_TOKEN, ATTR_LAST_SAVED_AT, @@ -54,8 +58,9 @@ from .const import ( FITBIT_AUTH_START, FITBIT_CONFIG_FILE, FITBIT_DEFAULT_RESOURCES, - FITBIT_MEASUREMENTS, + FitbitUnitSystem, ) +from .model import FitbitDevice _LOGGER: Final = logging.getLogger(__name__) @@ -64,11 +69,66 @@ _CONFIGURING: dict[str, str] = {} SCAN_INTERVAL: Final = datetime.timedelta(minutes=30) +def _default_value_fn(result: dict[str, Any]) -> str: + """Parse a Fitbit timeseries API responses.""" + return cast(str, result["value"]) + + +def _distance_value_fn(result: dict[str, Any]) -> int | str: + """Format function for distance values.""" + return format(float(_default_value_fn(result)), ".2f") + + +def _body_value_fn(result: dict[str, Any]) -> int | str: + """Format function for body values.""" + return format(float(_default_value_fn(result)), ".1f") + + +def _clock_format_12h(result: dict[str, Any]) -> str: + raw_state = result["value"] + if raw_state == "": + return "-" + hours_str, minutes_str = raw_state.split(":") + hours, minutes = int(hours_str), int(minutes_str) + setting = "AM" + if hours > 12: + setting = "PM" + hours -= 12 + elif hours == 0: + hours = 12 + return f"{hours}:{minutes:02d} {setting}" + + +def _weight_unit(unit_system: FitbitUnitSystem) -> UnitOfMass: + """Determine the weight unit.""" + if unit_system == FitbitUnitSystem.EN_US: + return UnitOfMass.POUNDS + if unit_system == FitbitUnitSystem.EN_GB: + return UnitOfMass.STONES + return UnitOfMass.KILOGRAMS + + +def _distance_unit(unit_system: FitbitUnitSystem) -> UnitOfLength: + """Determine the distance unit.""" + if unit_system == FitbitUnitSystem.EN_US: + return UnitOfLength.MILES + return UnitOfLength.KILOMETERS + + +def _elevation_unit(unit_system: FitbitUnitSystem) -> UnitOfLength: + """Determine the elevation unit.""" + if unit_system == FitbitUnitSystem.EN_US: + return UnitOfLength.FEET + return UnitOfLength.METERS + + @dataclass class FitbitSensorEntityDescription(SensorEntityDescription): """Describes Fitbit sensor entity.""" unit_type: str | None = None + value_fn: Callable[[dict[str, Any]], Any] = _default_value_fn + unit_fn: Callable[[FitbitUnitSystem], str | None] = lambda x: None FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( @@ -93,16 +153,17 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( FitbitSensorEntityDescription( key="activities/distance", name="Distance", - unit_type="distance", icon="mdi:map-marker", device_class=SensorDeviceClass.DISTANCE, + value_fn=_distance_value_fn, + unit_fn=_distance_unit, ), FitbitSensorEntityDescription( key="activities/elevation", name="Elevation", - unit_type="elevation", icon="mdi:walk", device_class=SensorDeviceClass.DISTANCE, + unit_fn=_elevation_unit, ), FitbitSensorEntityDescription( key="activities/floors", @@ -115,6 +176,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( name="Resting Heart Rate", native_unit_of_measurement="bpm", icon="mdi:heart-pulse", + value_fn=lambda result: int(result["value"]["restingHeartRate"]), ), FitbitSensorEntityDescription( key="activities/minutesFairlyActive", @@ -165,16 +227,17 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( FitbitSensorEntityDescription( key="activities/tracker/distance", name="Tracker Distance", - unit_type="distance", icon="mdi:map-marker", device_class=SensorDeviceClass.DISTANCE, + value_fn=_distance_value_fn, + unit_fn=_distance_unit, ), FitbitSensorEntityDescription( key="activities/tracker/elevation", name="Tracker Elevation", - unit_type="elevation", icon="mdi:walk", device_class=SensorDeviceClass.DISTANCE, + unit_fn=_elevation_unit, ), FitbitSensorEntityDescription( key="activities/tracker/floors", @@ -222,6 +285,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( native_unit_of_measurement="BMI", icon="mdi:human", state_class=SensorStateClass.MEASUREMENT, + value_fn=_body_value_fn, ), FitbitSensorEntityDescription( key="body/fat", @@ -229,14 +293,16 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( native_unit_of_measurement=PERCENTAGE, icon="mdi:human", state_class=SensorStateClass.MEASUREMENT, + value_fn=_body_value_fn, ), FitbitSensorEntityDescription( key="body/weight", name="Weight", - unit_type="weight", icon="mdi:human", state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.WEIGHT, + value_fn=_body_value_fn, + unit_fn=_weight_unit, ), FitbitSensorEntityDescription( key="sleep/awakeningsCount", @@ -279,11 +345,6 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( icon="mdi:sleep", device_class=SensorDeviceClass.DURATION, ), - FitbitSensorEntityDescription( - key="sleep/startTime", - name="Sleep Start Time", - icon="mdi:clock", - ), FitbitSensorEntityDescription( key="sleep/timeInBed", name="Sleep Time in Bed", @@ -293,6 +354,19 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( ), ) +# Different description depending on clock format +SLEEP_START_TIME = FitbitSensorEntityDescription( + key="sleep/startTime", + name="Sleep Start Time", + icon="mdi:clock", +) +SLEEP_START_TIME_12HR = FitbitSensorEntityDescription( + key="sleep/startTime", + name="Sleep Start Time", + icon="mdi:clock", + value_fn=_clock_format_12h, +) + FITBIT_RESOURCE_BATTERY = FitbitSensorEntityDescription( key="devices/battery", name="Battery", @@ -300,7 +374,8 @@ FITBIT_RESOURCE_BATTERY = FitbitSensorEntityDescription( ) FITBIT_RESOURCES_KEYS: Final[list[str]] = [ - desc.key for desc in (*FITBIT_RESOURCES_LIST, FITBIT_RESOURCE_BATTERY) + desc.key + for desc in (*FITBIT_RESOURCES_LIST, FITBIT_RESOURCE_BATTERY, SLEEP_START_TIME) ] PLATFORM_SCHEMA: Final = PARENT_PLATFORM_SCHEMA.extend( @@ -311,8 +386,13 @@ PLATFORM_SCHEMA: Final = PARENT_PLATFORM_SCHEMA.extend( vol.Optional(CONF_CLOCK_FORMAT, default=DEFAULT_CLOCK_FORMAT): vol.In( ["12H", "24H"] ), - vol.Optional(CONF_UNIT_SYSTEM, default="default"): vol.In( - ["en_GB", "en_US", "metric", "default"] + vol.Optional(CONF_UNIT_SYSTEM, default=FitbitUnitSystem.LEGACY_DEFAULT): vol.In( + [ + FitbitUnitSystem.EN_GB, + FitbitUnitSystem.EN_US, + FitbitUnitSystem.METRIC, + FitbitUnitSystem.LEGACY_DEFAULT, + ] ), } ) @@ -438,45 +518,46 @@ def setup_platform( if int(time.time()) - cast(int, expires_at) > 3600: authd_client.client.refresh_token() - user_profile = authd_client.user_profile_get()["user"] - if (unit_system := config[CONF_UNIT_SYSTEM]) == "default": - authd_client.system = user_profile["locale"] - if authd_client.system != "en_GB": - if hass.config.units is METRIC_SYSTEM: - authd_client.system = "metric" - else: - authd_client.system = "en_US" - else: - authd_client.system = unit_system + api = FitbitApi(hass, authd_client, config[CONF_UNIT_SYSTEM]) + user_profile = asyncio.run_coroutine_threadsafe( + api.async_get_user_profile(), hass.loop + ).result() + unit_system = asyncio.run_coroutine_threadsafe( + api.async_get_unit_system(), hass.loop + ).result() - registered_devs = authd_client.get_devices() clock_format = config[CONF_CLOCK_FORMAT] monitored_resources = config[CONF_MONITORED_RESOURCES] + resource_list = [ + *FITBIT_RESOURCES_LIST, + SLEEP_START_TIME_12HR if clock_format == "12H" else SLEEP_START_TIME, + ] entities = [ FitbitSensor( - authd_client, - user_profile, + api, + user_profile.encoded_id, config_path, description, - hass.config.units is METRIC_SYSTEM, - clock_format, + units=description.unit_fn(unit_system), ) - for description in FITBIT_RESOURCES_LIST + for description in resource_list if description.key in monitored_resources ] if "devices/battery" in monitored_resources: + devices = asyncio.run_coroutine_threadsafe( + api.async_get_devices(), + hass.loop, + ).result() entities.extend( [ FitbitSensor( - authd_client, - user_profile, + api, + user_profile.encoded_id, config_path, FITBIT_RESOURCE_BATTERY, - hass.config.units is METRIC_SYSTEM, - clock_format, - dev_extra, + device, ) - for dev_extra in registered_devs + for device in devices ] ) add_entities(entities, True) @@ -591,47 +672,34 @@ class FitbitSensor(SensorEntity): def __init__( self, - client: Fitbit, - user_profile: dict[str, Any], + api: FitbitApi, + user_profile_id: str, config_path: str, description: FitbitSensorEntityDescription, - is_metric: bool, - clock_format: str, - extra: dict[str, str] | None = None, + device: FitbitDevice | None = None, + units: str | None = None, ) -> None: """Initialize the Fitbit sensor.""" self.entity_description = description - self.client = client + self.api = api self.config_path = config_path - self.is_metric = is_metric - self.clock_format = clock_format - self.extra = extra + self.device = device - self._attr_unique_id = f"{user_profile['encodedId']}_{description.key}" - if self.extra is not None: - self._attr_name = f"{self.extra.get('deviceVersion')} Battery" - self._attr_unique_id = f"{self._attr_unique_id}_{self.extra.get('id')}" + self._attr_unique_id = f"{user_profile_id}_{description.key}" + if device is not None: + self._attr_name = f"{device.device_version} Battery" + self._attr_unique_id = f"{self._attr_unique_id}_{device.id}" - if description.unit_type: - try: - measurement_system = FITBIT_MEASUREMENTS[self.client.system] - except KeyError: - if self.is_metric: - measurement_system = FITBIT_MEASUREMENTS["metric"] - else: - measurement_system = FITBIT_MEASUREMENTS["en_US"] - split_resource = description.key.rsplit("/", maxsplit=1)[-1] - unit_type = measurement_system[split_resource] - self._attr_native_unit_of_measurement = unit_type + if units is not None: + self._attr_native_unit_of_measurement = units @property def icon(self) -> str | None: """Icon to use in the frontend, if any.""" if ( self.entity_description.key == "devices/battery" - and self.extra is not None - and (extra_battery := self.extra.get("battery")) is not None - and (battery_level := BATTERY_LEVELS.get(extra_battery)) is not None + and self.device is not None + and (battery_level := BATTERY_LEVELS.get(self.device.battery)) is not None ): return icon_for_battery_level(battery_level=battery_level) return self.entity_description.icon @@ -641,72 +709,37 @@ class FitbitSensor(SensorEntity): """Return the state attributes.""" attrs: dict[str, str | None] = {} - if self.extra is not None: - attrs["model"] = self.extra.get("deviceVersion") - extra_type = self.extra.get("type") - attrs["type"] = extra_type.lower() if extra_type is not None else None + if self.device is not None: + attrs["model"] = self.device.device_version + device_type = self.device.type + attrs["type"] = device_type.lower() if device_type is not None else None return attrs - def update(self) -> None: + async def async_update(self) -> None: """Get the latest data from the Fitbit API and update the states.""" resource_type = self.entity_description.key - if resource_type == "devices/battery" and self.extra is not None: - registered_devs: list[dict[str, Any]] = self.client.get_devices() - device_id = self.extra.get("id") - self.extra = list( - filter(lambda device: device.get("id") == device_id, registered_devs) - )[0] - self._attr_native_value = self.extra.get("battery") + if resource_type == "devices/battery" and self.device is not None: + device_id = self.device.id + registered_devs: list[FitbitDevice] = await self.api.async_get_devices() + self.device = next( + device for device in registered_devs if device.id == device_id + ) + self._attr_native_value = self.device.battery else: - container = resource_type.replace("/", "-") - response = self.client.time_series(resource_type, period="7d") - raw_state = response[container][-1].get("value") - if resource_type == "activities/distance": - self._attr_native_value = format(float(raw_state), ".2f") - elif resource_type == "activities/tracker/distance": - self._attr_native_value = format(float(raw_state), ".2f") - elif resource_type == "body/bmi": - self._attr_native_value = format(float(raw_state), ".1f") - elif resource_type == "body/fat": - self._attr_native_value = format(float(raw_state), ".1f") - elif resource_type == "body/weight": - self._attr_native_value = format(float(raw_state), ".1f") - elif resource_type == "sleep/startTime": - if raw_state == "": - self._attr_native_value = "-" - elif self.clock_format == "12H": - hours, minutes = raw_state.split(":") - hours, minutes = int(hours), int(minutes) - setting = "AM" - if hours > 12: - setting = "PM" - hours -= 12 - elif hours == 0: - hours = 12 - self._attr_native_value = f"{hours}:{minutes:02d} {setting}" - else: - self._attr_native_value = raw_state - elif self.is_metric: - self._attr_native_value = raw_state - else: - try: - self._attr_native_value = int(raw_state) - except TypeError: - self._attr_native_value = raw_state + result = await self.api.async_get_latest_time_series(resource_type) + self._attr_native_value = self.entity_description.value_fn(result) - if resource_type == "activities/heart": - self._attr_native_value = ( - response[container][-1].get("value").get("restingHeartRate") - ) + self.hass.async_add_executor_job(self._update_token) - token = self.client.client.session.token + def _update_token(self) -> None: + token = self.api.client.client.session.token config_contents = { ATTR_ACCESS_TOKEN: token.get("access_token"), ATTR_REFRESH_TOKEN: token.get("refresh_token"), - CONF_CLIENT_ID: self.client.client.client_id, - CONF_CLIENT_SECRET: self.client.client.client_secret, + CONF_CLIENT_ID: self.api.client.client.client_id, + CONF_CLIENT_SECRET: self.api.client.client.client_secret, ATTR_LAST_SAVED_AT: int(time.time()), } save_json(self.config_path, config_contents) diff --git a/homeassistant/components/flipr/__init__.py b/homeassistant/components/flipr/__init__.py index 81c21a4aa99..e6d7cb1dd17 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 0597145c2da..677a282e8cb 100644 --- a/homeassistant/components/flipr/binary_sensor.py +++ b/homeassistant/components/flipr/binary_sensor.py @@ -10,8 +10,8 @@ from homeassistant.config_entries import ConfigEntry 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 00000000000..d51db645035 --- /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 00000000000..6166d727ac7 --- /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 078e581edda..a8618b2df87 100644 --- a/homeassistant/components/flipr/sensor.py +++ b/homeassistant/components/flipr/sensor.py @@ -12,8 +12,8 @@ from homeassistant.const import PERCENTAGE, UnitOfElectricPotential, UnitOfTempe 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 18a4341db57..00e5e57498f 100644 --- a/homeassistant/components/flo/switch.py +++ b/homeassistant/components/flo/switch.py @@ -102,6 +102,7 @@ class FloSwitch(FloEntity, SwitchEntity): 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._device.async_add_listener(self.async_update_state)) async def async_set_mode_home(self): diff --git a/homeassistant/components/flux_led/manifest.json b/homeassistant/components/flux_led/manifest.json index d3274738f75..a55ae028342 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 890cd95784c..a517f1fea6f 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 1413dba23d4..201a3cd415c 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/binary_sensor.py b/homeassistant/components/freebox/binary_sensor.py index 10a151dbcf6..b5e0258d844 100644 --- a/homeassistant/components/freebox/binary_sensor.py +++ b/homeassistant/components/freebox/binary_sensor.py @@ -15,11 +15,13 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from .const import DOMAIN, FreeboxHomeCategory +from .home_base import FreeboxHomeEntity from .router import FreeboxRouter _LOGGER = logging.getLogger(__name__) + RAID_SENSORS: tuple[BinarySensorEntityDescription, ...] = ( BinarySensorEntityDescription( key="raid_degraded", @@ -33,21 +35,105 @@ RAID_SENSORS: tuple[BinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: - """Set up the binary sensors.""" + """Set up binary sensors.""" router: FreeboxRouter = hass.data[DOMAIN][entry.unique_id] _LOGGER.debug("%s - %s - %s raid(s)", router.name, router.mac, len(router.raids)) - binary_entities = [ + binary_entities: list[BinarySensorEntity] = [ FreeboxRaidDegradedSensor(router, raid, description) for raid in router.raids.values() for description in RAID_SENSORS ] + for node in router.home_devices.values(): + if node["category"] == FreeboxHomeCategory.PIR: + binary_entities.append(FreeboxPirSensor(hass, router, node)) + elif node["category"] == FreeboxHomeCategory.DWS: + binary_entities.append(FreeboxDwsSensor(hass, router, node)) + + for endpoint in node["show_endpoints"]: + if ( + endpoint["name"] == "cover" + and endpoint["ep_type"] == "signal" + and endpoint.get("value") is not None + ): + binary_entities.append(FreeboxCoverSensor(hass, router, node)) + if binary_entities: async_add_entities(binary_entities, True) +class FreeboxHomeBinarySensor(FreeboxHomeEntity, BinarySensorEntity): + """Representation of a Freebox binary sensor.""" + + _sensor_name = "trigger" + + def __init__( + self, + hass: HomeAssistant, + router: FreeboxRouter, + node: dict[str, Any], + sub_node: dict[str, Any] | None = None, + ) -> None: + """Initialize a Freebox binary sensor.""" + super().__init__(hass, router, node, sub_node) + self._command_id = self.get_command_id( + node["type"]["endpoints"], "signal", self._sensor_name + ) + self._attr_is_on = self._edit_state(self.get_value("signal", self._sensor_name)) + + async def async_update_signal(self): + """Update name & state.""" + self._attr_is_on = self._edit_state( + await self.get_home_endpoint_value(self._command_id) + ) + await FreeboxHomeEntity.async_update_signal(self) + + def _edit_state(self, state: bool | None) -> bool | None: + """Edit state depending on sensor name.""" + if state is None: + return None + if self._sensor_name == "trigger": + return not state + return state + + +class FreeboxPirSensor(FreeboxHomeBinarySensor): + """Representation of a Freebox motion binary sensor.""" + + _attr_device_class = BinarySensorDeviceClass.MOTION + + +class FreeboxDwsSensor(FreeboxHomeBinarySensor): + """Representation of a Freebox door opener binary sensor.""" + + _attr_device_class = BinarySensorDeviceClass.DOOR + + +class FreeboxCoverSensor(FreeboxHomeBinarySensor): + """Representation of a cover Freebox plastic removal cover binary sensor (for some sensors: motion detector, door opener detector...).""" + + _attr_device_class = BinarySensorDeviceClass.SAFETY + _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_entity_registry_enabled_default = False + + _sensor_name = "cover" + + def __init__( + self, hass: HomeAssistant, router: FreeboxRouter, node: dict[str, Any] + ) -> None: + """Initialize a cover for another device.""" + cover_node = next( + filter( + lambda x: (x["name"] == self._sensor_name and x["ep_type"] == "signal"), + node["type"]["endpoints"], + ), + None, + ) + super().__init__(hass, router, node, cover_node) + + class FreeboxRaidDegradedSensor(BinarySensorEntity): """Representation of a Freebox raid sensor.""" diff --git a/homeassistant/components/freebox/camera.py b/homeassistant/components/freebox/camera.py index fd11b949890..f5c86ec0bce 100644 --- a/homeassistant/components/freebox/camera.py +++ b/homeassistant/components/freebox/camera.py @@ -80,27 +80,27 @@ class FreeboxCamera(FreeboxHomeEntity, FFmpegCamera): ) self._command_motion_detection = self.get_command_id( - node["type"]["endpoints"], ATTR_DETECTION + node["type"]["endpoints"], "slot", ATTR_DETECTION ) self._attr_extra_state_attributes = {} self.update_node(node) async def async_enable_motion_detection(self) -> None: """Enable motion detection in the camera.""" - await self.set_home_endpoint_value(self._command_motion_detection, True) - self._attr_motion_detection_enabled = True + if await self.set_home_endpoint_value(self._command_motion_detection, True): + self._attr_motion_detection_enabled = True async def async_disable_motion_detection(self) -> None: """Disable motion detection in camera.""" - await self.set_home_endpoint_value(self._command_motion_detection, False) - self._attr_motion_detection_enabled = False + if await self.set_home_endpoint_value(self._command_motion_detection, False): + self._attr_motion_detection_enabled = False async def async_update_signal(self) -> None: """Update the camera node.""" self.update_node(self._router.home_devices[self._id]) self.async_write_ha_state() - def update_node(self, node): + def update_node(self, node: dict[str, Any]) -> None: """Update params.""" self._name = node["label"].strip() diff --git a/homeassistant/components/freebox/const.py b/homeassistant/components/freebox/const.py index 5bed7b3456a..0c3450d13b6 100644 --- a/homeassistant/components/freebox/const.py +++ b/homeassistant/components/freebox/const.py @@ -86,6 +86,8 @@ CATEGORY_TO_MODEL = { HOME_COMPATIBLE_CATEGORIES = [ FreeboxHomeCategory.CAMERA, FreeboxHomeCategory.DWS, + FreeboxHomeCategory.IOHOME, FreeboxHomeCategory.KFB, FreeboxHomeCategory.PIR, + FreeboxHomeCategory.RTS, ] diff --git a/homeassistant/components/freebox/home_base.py b/homeassistant/components/freebox/home_base.py index d0bb8b10309..2cc1a5fcfe3 100644 --- a/homeassistant/components/freebox/home_base.py +++ b/homeassistant/components/freebox/home_base.py @@ -77,23 +77,36 @@ class FreeboxHomeEntity(Entity): ) self.async_write_ha_state() - async def set_home_endpoint_value(self, command_id: Any, value=None) -> None: + async def set_home_endpoint_value(self, command_id: Any, value=None) -> bool: """Set Home endpoint value.""" if command_id is None: _LOGGER.error("Unable to SET a value through the API. Command is None") - return + return False + await self._router.home.set_home_endpoint_value( self._id, command_id, {"value": value} ) + return True - def get_command_id(self, nodes, name) -> int | None: + async def get_home_endpoint_value(self, command_id: Any) -> Any | None: + """Get Home endpoint value.""" + if command_id is None: + _LOGGER.error("Unable to GET a value through the API. Command is None") + return None + + node = await self._router.home.get_home_endpoint_value(self._id, command_id) + return node.get("value") + + def get_command_id(self, nodes, ep_type, name) -> int | None: """Get the command id.""" node = next( - filter(lambda x: (x["name"] == name), nodes), + filter(lambda x: (x["name"] == name and x["ep_type"] == ep_type), nodes), None, ) if not node: - _LOGGER.warning("The Freebox Home device has no value for: %s", name) + _LOGGER.warning( + "The Freebox Home device has no command value for: %s/%s", name, ep_type + ) return None return node["id"] @@ -115,7 +128,7 @@ class FreeboxHomeEntity(Entity): """Register state update callback.""" self._remove_signal_update = dispacher - def get_value(self, ep_type, name): + def get_value(self, ep_type: str, name: str): """Get the value.""" node = next( filter( @@ -126,7 +139,7 @@ class FreeboxHomeEntity(Entity): ) if not node: _LOGGER.warning( - "The Freebox Home device has no node for: %s/%s", ep_type, name + "The Freebox Home device has no node value for: %s/%s", ep_type, name ) return None return node.get("value") diff --git a/homeassistant/components/freebox/router.py b/homeassistant/components/freebox/router.py index cd5862a2f80..6a73624a776 100644 --- a/homeassistant/components/freebox/router.py +++ b/homeassistant/components/freebox/router.py @@ -118,6 +118,7 @@ class FreeboxRouter: async def update_sensors(self) -> None: """Update Freebox sensors.""" + # System sensors syst_datas: dict[str, Any] = await self._api.system.get_config() @@ -145,7 +146,6 @@ class FreeboxRouter: self.call_list = await self._api.call.get_calls_log() await self._update_disks_sensors() - await self._update_raids_sensors() async_dispatcher_send(self.hass, self.signal_sensor_update) @@ -165,6 +165,7 @@ class FreeboxRouter: async def _update_raids_sensors(self) -> None: """Update Freebox raids.""" + # None at first request if not self.supports_raid: return diff --git a/homeassistant/components/fritz/common.py b/homeassistant/components/fritz/common.py index 76368175ca0..2abba137fbf 100644 --- a/homeassistant/components/fritz/common.py +++ b/homeassistant/components/fritz/common.py @@ -566,7 +566,7 @@ class FritzBoxTools( 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 diff --git a/homeassistant/components/fritzbox/__init__.py b/homeassistant/components/fritzbox/__init__.py index d199d2c5a2c..8cb41ebcbe1 100644 --- a/homeassistant/components/fritzbox/__init__.py +++ b/homeassistant/components/fritzbox/__init__.py @@ -18,7 +18,7 @@ from homeassistant.const import ( ) from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntry, DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_entries from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -103,6 +103,24 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok +async def async_remove_config_entry_device( + hass: HomeAssistant, entry: ConfigEntry, device: DeviceEntry +) -> bool: + """Remove Fritzbox config entry from a device.""" + coordinator: FritzboxDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ + CONF_COORDINATOR + ] + + for identifier in device.identifiers: + if identifier[0] == DOMAIN and ( + identifier[1] in coordinator.data.devices + or identifier[1] in coordinator.data.templates + ): + return False + + return True + + class FritzBoxEntity(CoordinatorEntity[FritzboxDataUpdateCoordinator], ABC): """Basis FritzBox entity.""" diff --git a/homeassistant/components/fritzbox_callmonitor/sensor.py b/homeassistant/components/fritzbox_callmonitor/sensor.py index c8902622f85..cc239895c38 100644 --- a/homeassistant/components/fritzbox_callmonitor/sensor.py +++ b/homeassistant/components/fritzbox_callmonitor/sensor.py @@ -12,7 +12,7 @@ from typing import Any, cast 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, diff --git a/homeassistant/components/fritzbox_callmonitor/strings.json b/homeassistant/components/fritzbox_callmonitor/strings.json index 6b2fa2943f9..89f049bfbe9 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/__init__.py b/homeassistant/components/frontend/__init__.py index 59315e9f576..a5a4d76f9e7 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -156,9 +156,18 @@ MANIFEST_JSON = Manifest( "src": f"/static/icons/favicon-{size}x{size}.png", "sizes": f"{size}x{size}", "type": "image/png", - "purpose": "maskable any", + "purpose": "any", } for size in (192, 384, 512, 1024) + ] + + [ + { + "src": f"/static/icons/maskable_icon-{size}x{size}.png", + "sizes": f"{size}x{size}", + "type": "image/png", + "purpose": "maskable", + } + for size in (48, 72, 96, 128, 192, 384, 512) ], "screenshots": [ { @@ -171,6 +180,7 @@ MANIFEST_JSON = Manifest( "name": "Home Assistant", "short_name": "Assistant", "start_url": "/?homescreen=1", + "id": "/?homescreen=1", "theme_color": DEFAULT_THEME_COLOR, "prefer_related_applications": True, "related_applications": [ diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 6291e3a237e..40339e955f9 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==20230911.0"] + "requirements": ["home-assistant-frontend==20231002.0"] } diff --git a/homeassistant/components/garadget/cover.py b/homeassistant/components/garadget/cover.py index 826f21e9f88..6d9705cee75 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 @@ class GaradgetCover(CoverEntity): 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 3e07eb1ad42..bcbb25d55a2 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.4.0"] + "requirements": ["gardena-bluetooth==1.4.0"] } diff --git a/homeassistant/components/generic/manifest.json b/homeassistant/components/generic/manifest.json index a89ee370920..2966d668ac9 100644 --- a/homeassistant/components/generic/manifest.json +++ b/homeassistant/components/generic/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["http"], "documentation": "https://www.home-assistant.io/integrations/generic", "iot_class": "local_push", - "requirements": ["ha-av==10.1.1", "Pillow==10.0.0"] + "requirements": ["ha-av==10.1.1", "Pillow==10.0.1"] } diff --git a/homeassistant/components/geniushub/__init__.py b/homeassistant/components/geniushub/__init__.py index bed622eebf6..955c76fe0fc 100644 --- a/homeassistant/components/geniushub/__init__.py +++ b/homeassistant/components/geniushub/__init__.py @@ -212,11 +212,10 @@ class GeniusBroker: 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 @@ class GeniusZone(GeniusEntity): 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 06237b6e8d5..22d95be079e 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 @@ class GeniusBattery(GeniusDevice, SensorEntity): 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 04e133248a6..72555b629d7 100644 --- a/homeassistant/components/glances/config_flow.py +++ b/homeassistant/components/glances/config_flow.py @@ -1,12 +1,16 @@ """Config flow for Glances.""" from __future__ import annotations +from collections.abc import Mapping from typing import Any -from glances_api.exceptions import GlancesApiError +from glances_api.exceptions import ( + GlancesApiAuthorizationError, + GlancesApiConnectionError, +) import voluptuous as vol -from homeassistant import config_entries, exceptions +from homeassistant import config_entries from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -15,7 +19,6 @@ from homeassistant.const import ( CONF_USERNAME, CONF_VERIFY_SSL, ) -from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult from . import get_api @@ -41,19 +44,53 @@ DATA_SCHEMA = vol.Schema( ) -async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: - """Validate the user input allows us to connect.""" - api = get_api(hass, data) - try: - await api.get_ha_sensor_data() - except GlancesApiError as err: - raise CannotConnect from err - - class GlancesFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle a Glances config flow.""" VERSION = 1 + _reauth_entry: config_entries.ConfigEntry | None + + 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"] + ) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: + """Confirm reauth dialog.""" + errors = {} + assert self._reauth_entry + if user_input is not None: + user_input = {**self._reauth_entry.data, **user_input} + api = get_api(self.hass, user_input) + try: + await api.get_ha_sensor_data() + except GlancesApiAuthorizationError: + errors["base"] = "invalid_auth" + except GlancesApiConnectionError: + errors["base"] = "cannot_connect" + else: + self.hass.config_entries.async_update_entry( + self._reauth_entry, data=user_input + ) + await self.hass.config_entries.async_reload(self._reauth_entry.entry_id) + return self.async_abort(reason="reauth_successful") + + return self.async_show_form( + description_placeholders={ + CONF_USERNAME: self._reauth_entry.data[CONF_USERNAME] + }, + step_id="reauth_confirm", + data_schema=vol.Schema( + { + vol.Required(CONF_PASSWORD): str, + } + ), + errors=errors, + ) async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -61,19 +98,22 @@ class GlancesFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """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]} + ) + api = get_api(self.hass, user_input) try: - await validate_input(self.hass, user_input) - return self.async_create_entry( - title=user_input[CONF_HOST], data=user_input - ) - except CannotConnect: + await api.get_ha_sensor_data() + except GlancesApiAuthorizationError: + errors["base"] = "invalid_auth" + except GlancesApiConnectionError: errors["base"] = "cannot_connect" + else: + return self.async_create_entry( + title=f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}", + data=user_input, + ) return self.async_show_form( step_id="user", data_schema=DATA_SCHEMA, errors=errors ) - - -class CannotConnect(exceptions.HomeAssistantError): - """Error to indicate we cannot connect.""" diff --git a/homeassistant/components/glances/coordinator.py b/homeassistant/components/glances/coordinator.py index 24a2e23a013..9fa9346b95f 100644 --- a/homeassistant/components/glances/coordinator.py +++ b/homeassistant/components/glances/coordinator.py @@ -7,6 +7,7 @@ from glances_api import Glances, exceptions from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DEFAULT_SCAN_INTERVAL, DOMAIN @@ -35,6 +36,9 @@ class GlancesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): 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.GlancesApiAuthorizationError as err: + raise ConfigEntryAuthFailed from err 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 cd9c3a9135d..78aa5ffbf0a 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 @@ class GlancesSensor(CoordinatorEntity[GlancesDataUpdateCoordinator], SensorEntit 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/glances/strings.json b/homeassistant/components/glances/strings.json index b46716b43c0..fdd0c44b31b 100644 --- a/homeassistant/components/glances/strings.json +++ b/homeassistant/components/glances/strings.json @@ -11,13 +11,21 @@ "ssl": "[%key:common::config_flow::data::ssl%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" } + }, + "reauth_confirm": { + "description": "The password for {username} is invalid.", + "data": { + "password": "[%key:common::config_flow::data::password%]" + } } }, "error": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" }, "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/gogogate2/manifest.json b/homeassistant/components/gogogate2/manifest.json index faebcf7e353..40633537ddf 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 0ae064e0e97..ac91fba787d 100644 --- a/homeassistant/components/goodwe/coordinator.py +++ b/homeassistant/components/goodwe/coordinator.py @@ -30,7 +30,6 @@ class GoodweUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): _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/const.py b/homeassistant/components/google_assistant/const.py index 6ec8ca5d747..060f7ce50e5 100644 --- a/homeassistant/components/google_assistant/const.py +++ b/homeassistant/components/google_assistant/const.py @@ -6,6 +6,7 @@ from homeassistant.components import ( camera, climate, cover, + event, fan, group, humidifier, @@ -48,6 +49,7 @@ DEFAULT_EXPOSED_DOMAINS = [ "binary_sensor", "climate", "cover", + "event", "fan", "group", "humidifier", @@ -73,6 +75,7 @@ TYPE_CAMERA = f"{PREFIX_TYPES}CAMERA" TYPE_CURTAIN = f"{PREFIX_TYPES}CURTAIN" TYPE_DEHUMIDIFIER = f"{PREFIX_TYPES}DEHUMIDIFIER" TYPE_DOOR = f"{PREFIX_TYPES}DOOR" +TYPE_DOORBELL = f"{PREFIX_TYPES}DOORBELL" TYPE_FAN = f"{PREFIX_TYPES}FAN" TYPE_GARAGE = f"{PREFIX_TYPES}GARAGE" TYPE_HUMIDIFIER = f"{PREFIX_TYPES}HUMIDIFIER" @@ -162,6 +165,7 @@ DEVICE_CLASS_TO_GOOGLE_TYPES = { (cover.DOMAIN, cover.CoverDeviceClass.GATE): TYPE_GARAGE, (cover.DOMAIN, cover.CoverDeviceClass.SHUTTER): TYPE_SHUTTER, (cover.DOMAIN, cover.CoverDeviceClass.WINDOW): TYPE_WINDOW, + (event.DOMAIN, event.EventDeviceClass.DOORBELL): TYPE_DOORBELL, ( humidifier.DOMAIN, humidifier.HumidifierDeviceClass.DEHUMIDIFIER, diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py index 49d130d6656..ee8e5872348 100644 --- a/homeassistant/components/google_assistant/helpers.py +++ b/homeassistant/components/google_assistant/helpers.py @@ -5,9 +5,11 @@ from abc import ABC, abstractmethod 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 +from typing import Any from aiohttp.web import json_response from awesomeversion import AwesomeVersion @@ -182,7 +184,9 @@ class AbstractConfig(ABC): """If an entity should have 2FA checked.""" return True - async def async_report_state(self, message, agent_user_id: str): + async def async_report_state( + self, message: dict[str, Any], agent_user_id: str, event_id: str | None = None + ) -> HTTPStatus | None: """Send a state report to Google.""" raise NotImplementedError @@ -233,6 +237,33 @@ class AbstractConfig(ABC): ) return max(res, default=204) + async def async_sync_notification( + self, agent_user_id: str, event_id: str, payload: dict[str, Any] + ) -> HTTPStatus: + """Sync notification to Google.""" + # Remove any pending sync + self._google_sync_unsub.pop(agent_user_id, lambda: None)() + status = await self.async_report_state(payload, agent_user_id, event_id) + assert status is not None + if status == HTTPStatus.NOT_FOUND: + await self.async_disconnect_agent_user(agent_user_id) + return status + + async def async_sync_notification_all( + self, event_id: str, payload: dict[str, Any] + ) -> HTTPStatus: + """Sync notification to Google for all registered agents.""" + if not self._store.agent_user_ids: + return HTTPStatus.NO_CONTENT + + res = await gather( + *( + self.async_sync_notification(agent_user_id, event_id, payload) + for agent_user_id in self._store.agent_user_ids + ) + ) + return max(res, default=HTTPStatus.NO_CONTENT) + @callback def async_schedule_google_sync(self, agent_user_id: str): """Schedule a sync.""" @@ -490,9 +521,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 +558,10 @@ class GoogleEntity: 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 +572,10 @@ class GoogleEntity: """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 +598,8 @@ class GoogleEntity: @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: @@ -613,7 +647,6 @@ class GoogleEntity: state.domain, state.attributes.get(ATTR_DEVICE_CLASS) ), } - # Add aliases if (config_aliases := entity_config.get(CONF_ALIASES, [])) or ( entity_entry and entity_entry.aliases @@ -635,6 +668,10 @@ class GoogleEntity: for trt in traits: device["attributes"].update(trt.sync_attributes()) + # Add trait options + for trt in traits: + device.update(trt.sync_options()) + # Add roomhint if room := entity_config.get(CONF_ROOM_HINT): device["roomHint"] = room @@ -677,6 +714,16 @@ class GoogleEntity: return attrs + @callback + def notifications_serialize(self) -> dict[str, Any] | None: + """Serialize the payload for notifications to be sent.""" + notifications: dict[str, Any] = {} + + for trt in self.traits(): + deep_update(notifications, trt.query_notifications() or {}) + + return notifications or None + @callback def reachable_device_serialize(self): """Serialize entity for a REACHABLE_DEVICE response.""" @@ -725,19 +772,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/http.py b/homeassistant/components/google_assistant/http.py index 84d5e4a3364..c0e4f715c16 100644 --- a/homeassistant/components/google_assistant/http.py +++ b/homeassistant/components/google_assistant/http.py @@ -158,7 +158,7 @@ class GoogleConfig(AbstractConfig): """If an entity should have 2FA checked.""" return True - async def _async_request_sync_devices(self, agent_user_id: str): + async def _async_request_sync_devices(self, agent_user_id: str) -> HTTPStatus: if CONF_SERVICE_ACCOUNT in self._config: return await self.async_call_homegraph_api( REQUEST_SYNC_BASE_URL, {"agentUserId": agent_user_id} @@ -220,14 +220,18 @@ class GoogleConfig(AbstractConfig): _LOGGER.error("Could not contact %s", url) return HTTPStatus.INTERNAL_SERVER_ERROR - async def async_report_state(self, message, agent_user_id: str): + async def async_report_state( + self, message: dict[str, Any], agent_user_id: str, event_id: str | None = None + ) -> HTTPStatus: """Send a state report to Google.""" data = { "requestId": uuid4().hex, "agentUserId": agent_user_id, "payload": message, } - await self.async_call_homegraph_api(REPORT_STATE_BASE_URL, data) + if event_id is not None: + data["eventId"] = event_id + return await self.async_call_homegraph_api(REPORT_STATE_BASE_URL, data) class GoogleAssistantView(HomeAssistantView): diff --git a/homeassistant/components/google_assistant/report_state.py b/homeassistant/components/google_assistant/report_state.py index 5248ce7c4da..87af12ad0fc 100644 --- a/homeassistant/components/google_assistant/report_state.py +++ b/homeassistant/components/google_assistant/report_state.py @@ -4,15 +4,20 @@ from __future__ import annotations from collections import deque import logging from typing import Any +from uuid import uuid4 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 @@ -26,7 +31,7 @@ _LOGGER = logging.getLogger(__name__) @callback def async_enable_report_state(hass: HomeAssistant, google_config: AbstractConfig): - """Enable state reporting.""" + """Enable state and notification reporting.""" checker = None unsub_pending: CALLBACK_TYPE | None = None pending: deque[dict[str, Any]] = deque([{}]) @@ -54,8 +59,10 @@ def async_enable_report_state(hass: HomeAssistant, google_config: AbstractConfig 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,17 +73,37 @@ def async_enable_report_state(hass: HomeAssistant, google_config: AbstractConfig 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 + if (notifications := entity.notifications_serialize()) is not None: + event_id = uuid4().hex + payload = { + "devices": {"notifications": {entity.state.entity_id: notifications}} + } + _LOGGER.info( + "Sending event notification for entity %s", + entity.state.entity_id, + ) + result = await google_config.async_sync_notification_all(event_id, payload) + if result != 200: + _LOGGER.error( + "Unable to send notification with result code: %s, check log for more" + " info", + result, + ) + try: entity_data = entity.query_serialize() except SmartHomeError as err: _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/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index 425a394b522..a39dfd3f3dc 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -2,6 +2,7 @@ from __future__ import annotations from abc import ABC, abstractmethod +from datetime import datetime, timedelta import logging from typing import Any, TypeVar @@ -12,6 +13,7 @@ from homeassistant.components import ( camera, climate, cover, + event, fan, group, humidifier, @@ -74,9 +76,10 @@ from homeassistant.const import ( STATE_UNKNOWN, UnitOfTemperature, ) -from homeassistant.core import DOMAIN as HA_DOMAIN +from homeassistant.core import DOMAIN as HA_DOMAIN, HomeAssistant from homeassistant.helpers.network import get_url from homeassistant.util import color as color_util, dt as dt_util +from homeassistant.util.dt import utcnow from homeassistant.util.percentage import ( ordered_list_item_to_percentage, percentage_to_ordered_list_item, @@ -115,6 +118,7 @@ TRAIT_LOCKUNLOCK = f"{PREFIX_TRAITS}LockUnlock" TRAIT_FANSPEED = f"{PREFIX_TRAITS}FanSpeed" TRAIT_MODES = f"{PREFIX_TRAITS}Modes" TRAIT_INPUTSELECTOR = f"{PREFIX_TRAITS}InputSelector" +TRAIT_OBJECTDETECTION = f"{PREFIX_TRAITS}ObjectDetection" TRAIT_OPENCLOSE = f"{PREFIX_TRAITS}OpenClose" TRAIT_VOLUME = f"{PREFIX_TRAITS}Volume" TRAIT_ARMDISARM = f"{PREFIX_TRAITS}ArmDisarm" @@ -221,7 +225,7 @@ class _Trait(ABC): def supported(domain, features, device_class, attributes): """Test if state is supported.""" - def __init__(self, hass, state, config): + def __init__(self, hass: HomeAssistant, state, config) -> None: """Initialize a trait for a state.""" self.hass = hass self.state = state @@ -231,10 +235,17 @@ class _Trait(ABC): """Return attributes for a sync request.""" raise NotImplementedError + def sync_options(self) -> dict[str, Any]: + """Add options for the sync request.""" + return {} + def query_attributes(self): """Return the attributes of this trait for this entity.""" raise NotImplementedError + def query_notifications(self) -> dict[str, Any] | None: + """Return notifications payload.""" + def can_execute(self, command, params): """Test if command can be executed.""" return command in self.commands @@ -335,6 +346,60 @@ class CameraStreamTrait(_Trait): } +@register_trait +class ObjectDetection(_Trait): + """Trait to object detection. + + https://developers.google.com/actions/smarthome/traits/objectdetection + """ + + name = TRAIT_OBJECTDETECTION + commands = [] + + @staticmethod + def supported(domain, features, device_class, _) -> bool: + """Test if state is supported.""" + return ( + domain == event.DOMAIN and device_class == event.EventDeviceClass.DOORBELL + ) + + def sync_attributes(self): + """Return ObjectDetection attributes for a sync request.""" + return {} + + def sync_options(self) -> dict[str, Any]: + """Add options for the sync request.""" + return {"notificationSupportedByAgent": True} + + def query_attributes(self): + """Return ObjectDetection query attributes.""" + return {} + + def query_notifications(self) -> dict[str, Any] | None: + """Return notifications payload.""" + + if self.state.state in {STATE_UNKNOWN, STATE_UNAVAILABLE}: + return None + + # Only notify if last event was less then 30 seconds ago + time_stamp = datetime.fromisoformat(self.state.state) + if (utcnow() - time_stamp) > timedelta(seconds=30): + return None + + return { + "ObjectDetection": { + "objects": { + "unclassified": 1, + }, + "priority": 0, + "detectionTimestamp": int(time_stamp.timestamp() * 1000), + }, + } + + async def execute(self, command, data, params, challenge): + """Execute an ObjectDetection command.""" + + @register_trait class OnOffTrait(_Trait): """Trait to offer basic on and off functionality. diff --git a/homeassistant/components/google_maps/device_tracker.py b/homeassistant/components/google_maps/device_tracker.py index 2ee12f0154c..be776df1751 100644 --- a/homeassistant/components/google_maps/device_tracker.py +++ b/homeassistant/components/google_maps/device_tracker.py @@ -112,12 +112,22 @@ class GoogleMapsScanner: last_seen = dt_util.as_utc(person.datetime) if last_seen < self._prev_seen.get(dev_id, last_seen): - _LOGGER.warning( + _LOGGER.debug( "Ignoring %s update because timestamp is older than last timestamp", person.nickname, ) _LOGGER.debug("%s < %s", last_seen, self._prev_seen[dev_id]) continue + if last_seen == self._prev_seen.get(dev_id, last_seen) and hasattr( + self, "success_init" + ): + _LOGGER.debug( + "Ignoring %s update because timestamp " + "is the same as the last timestamp %s", + person.nickname, + last_seen, + ) + continue self._prev_seen[dev_id] = last_seen attrs = { diff --git a/homeassistant/components/google_travel_time/strings.json b/homeassistant/components/google_travel_time/strings.json index 78b84038c7f..270f8fe31e2 100644 --- a/homeassistant/components/google_travel_time/strings.json +++ b/homeassistant/components/google_travel_time/strings.json @@ -3,7 +3,7 @@ "config": { "step": { "user": { - "description": "When specifying the origin and destination, you can supply one or more locations separated by the pipe character, in the form of an address, latitude/longitude coordinates, or a Google place ID. When specifying the location using a Google place ID, the ID must be prefixed with `place_id:`.", + "description": "You can specify the origin and destination in the form of an address, latitude/longitude coordinates, or a Google place ID. When specifying the location using a Google place ID, the ID must be prefixed with `place_id:`.", "data": { "name": "[%key:common::config_flow::data::name%]", "api_key": "[%key:common::config_flow::data::api_key%]", diff --git a/homeassistant/components/gpsd/sensor.py b/homeassistant/components/gpsd/sensor.py index 28ca9d3f075..64b86434c3c 100644 --- a/homeassistant/components/gpsd/sensor.py +++ b/homeassistant/components/gpsd/sensor.py @@ -3,6 +3,7 @@ from __future__ import annotations 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 @@ class GpsdSensor(SensorEntity): 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 @@ class GpsdSensor(SensorEntity): 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 @@ class GpsdSensor(SensorEntity): 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 33a4947c01d..fcf4d004d26 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 ef011c4308a..364ef15fa5e 100644 --- a/homeassistant/components/group/__init__.py +++ b/homeassistant/components/group/__init__.py @@ -42,7 +42,6 @@ from homeassistant.helpers.event import ( 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 @@ class GroupEntity(Entity): 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/media_player.py b/homeassistant/components/group/media_player.py index 3960f400614..bc238519cfa 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 9138b4ef348..00000000000 --- 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 270309149ef..75b2535bd44 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -49,7 +49,7 @@ from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.loader import bind_hass -from homeassistant.util.dt import utcnow +from homeassistant.util.dt import now from .addon_manager import AddonError, AddonInfo, AddonManager, AddonState # noqa: F401 from .addon_panel import async_setup_addon_panel @@ -88,11 +88,13 @@ from .handler import ( # noqa: F401 async_get_addon_discovery_info, async_get_addon_info, async_get_addon_store_info, + async_get_green_settings, async_get_yellow_settings, async_install_addon, async_reboot_host, async_restart_addon, async_set_addon_options, + async_set_green_settings, async_set_yellow_settings, async_start_addon, async_stop_addon, @@ -177,7 +179,7 @@ SCHEMA_ADDON_STDIN = SCHEMA_ADDON.extend( SCHEMA_BACKUP_FULL = vol.Schema( { vol.Optional( - ATTR_NAME, default=lambda: utcnow().strftime("%Y-%m-%d %H:%M:%S") + ATTR_NAME, default=lambda: now().strftime("%Y-%m-%d %H:%M:%S") ): cv.string, vol.Optional(ATTR_PASSWORD): cv.string, vol.Optional(ATTR_COMPRESSED): cv.boolean, diff --git a/homeassistant/components/hassio/handler.py b/homeassistant/components/hassio/handler.py index 020a4365ec6..fe9e1ba1d2e 100644 --- a/homeassistant/components/hassio/handler.py +++ b/homeassistant/components/hassio/handler.py @@ -263,6 +263,27 @@ async def async_apply_suggestion(hass: HomeAssistant, suggestion_uuid: str) -> b return await hassio.send_command(command, timeout=None) +@api_data +async def async_get_green_settings(hass: HomeAssistant) -> dict[str, bool]: + """Return settings specific to Home Assistant Green.""" + hassio: HassIO = hass.data[DOMAIN] + return await hassio.send_command("/os/boards/green", method="get") + + +@api_data +async def async_set_green_settings( + hass: HomeAssistant, settings: dict[str, bool] +) -> dict: + """Set settings specific to Home Assistant Green. + + Returns an empty dict. + """ + hassio: HassIO = hass.data[DOMAIN] + return await hassio.send_command( + "/os/boards/green", method="post", payload=settings + ) + + @api_data async def async_get_yellow_settings(hass: HomeAssistant) -> dict[str, bool]: """Return settings specific to Home Assistant Yellow.""" diff --git a/homeassistant/components/hdmi_cec/__init__.py b/homeassistant/components/hdmi_cec/__init__.py index 459f03edfbb..19621e28d03 100644 --- a/homeassistant/components/hdmi_cec/__init__.py +++ b/homeassistant/components/hdmi_cec/__init__.py @@ -123,11 +123,12 @@ SERVICE_SELECT_DEVICE = "select_device" 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 e2487e90a99..8502dec28fa 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 @@ class HeosMediaPlayer(MediaPlayerEntity): 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 @@ class HeosMediaPlayer(MediaPlayerEntity): """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 @@ class HeosMediaPlayer(MediaPlayerEntity): 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 @@ class HeosMediaPlayer(MediaPlayerEntity): """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 113a0c622b9..ca5ec694eab 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 @@ class ClimateAehW4a1(ClimateEntity): 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 @@ class ClimateAehW4a1(ClimateEntity): 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 @@ class ClimateAehW4a1(ClimateEntity): 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 @@ class ClimateAehW4a1(ClimateEntity): 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 @@ class ClimateAehW4a1(ClimateEntity): 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 @@ class ClimateAehW4a1(ClimateEntity): 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 f80972da613..9be0b5203fd 100644 --- a/homeassistant/components/hlk_sw16/__init__.py +++ b/homeassistant/components/hlk_sw16/__init__.py @@ -147,12 +147,8 @@ class SW16Device(Entity): 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 @@ class SW16Device(Entity): 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 12fe7be3be9..d60f8a96e09 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 @@ class HomeConnectEntity(Entity): 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 17dc842358f..7e65fed034d 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 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): 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 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): ) 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 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): 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 efd2a9b34dd..07edfb4bd4b 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 61dd11dbc6f..dbcbfde9dc2 100644 --- a/homeassistant/components/home_connect/switch.py +++ b/homeassistant/components/home_connect/switch.py @@ -56,13 +56,6 @@ class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity): ) 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 @@ class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity): """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 @@ class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity): ) 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 @@ class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity): ) 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 @@ class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity): 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 @@ class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity): "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 987a4317ba8..e4032ad954d 100644 --- a/homeassistant/components/homeassistant/__init__.py +++ b/homeassistant/components/homeassistant/__init__.py @@ -9,6 +9,7 @@ from homeassistant.auth.permissions.const import CAT_ENTITIES, POLICY_CONTROL 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_setup(hass: ha.HomeAssistant, config: ConfigType) -> bool: # no 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 899fee357fd..892e577490d 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: @@ -45,3 +60,5 @@ reload_config_entry: text: save_persistent_states: + +reload_all: diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json index 5404ee4af64..a3435a8d1f5 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." } } }, @@ -121,6 +125,10 @@ "save_persistent_states": { "name": "Save persistent states", "description": "Saves the persistent states immediately. Maintains the normal periodic saving interval." + }, + "reload_all": { + "name": "Reload all", + "description": "Reload all YAML configuration that can be reloaded without restarting Home Assistant." } } } diff --git a/homeassistant/components/homeassistant_green/config_flow.py b/homeassistant/components/homeassistant_green/config_flow.py index 17ba9aacbc5..c3491de430e 100644 --- a/homeassistant/components/homeassistant_green/config_flow.py +++ b/homeassistant/components/homeassistant_green/config_flow.py @@ -1,22 +1,100 @@ """Config flow for the Home Assistant Green integration.""" from __future__ import annotations +import asyncio +import logging from typing import Any -from homeassistant.config_entries import ConfigFlow +import aiohttp +import voluptuous as vol + +from homeassistant.components.hassio import ( + HassioAPIError, + async_get_green_settings, + async_set_green_settings, + is_hassio, +) +from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow +from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import selector from .const import DOMAIN +_LOGGER = logging.getLogger(__name__) + +STEP_HW_SETTINGS_SCHEMA = vol.Schema( + { + # Sorted to match front panel left to right + vol.Required("power_led"): selector.BooleanSelector(), + vol.Required("activity_led"): selector.BooleanSelector(), + vol.Required("system_health_led"): selector.BooleanSelector(), + } +) + class HomeAssistantGreenConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Home Assistant Green.""" VERSION = 1 + @staticmethod + @callback + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> HomeAssistantGreenOptionsFlow: + """Return the options flow.""" + return HomeAssistantGreenOptionsFlow() + async def async_step_system(self, data: dict[str, Any] | None = None) -> FlowResult: """Handle the initial step.""" if self._async_current_entries(): return self.async_abort(reason="single_instance_allowed") return self.async_create_entry(title="Home Assistant Green", data={}) + + +class HomeAssistantGreenOptionsFlow(OptionsFlow): + """Handle an option flow for Home Assistant Green.""" + + _hw_settings: dict[str, bool] | None = None + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Manage the options.""" + if not is_hassio(self.hass): + return self.async_abort(reason="not_hassio") + + return await self.async_step_hardware_settings() + + async def async_step_hardware_settings( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle hardware settings.""" + + if user_input is not None: + if self._hw_settings == user_input: + return self.async_create_entry(data={}) + try: + async with asyncio.timeout(10): + await async_set_green_settings(self.hass, user_input) + except (aiohttp.ClientError, TimeoutError, HassioAPIError) as err: + _LOGGER.warning("Failed to write hardware settings", exc_info=err) + return self.async_abort(reason="write_hw_settings_error") + return self.async_create_entry(data={}) + + try: + async with asyncio.timeout(10): + self._hw_settings: dict[str, bool] = await async_get_green_settings( + self.hass + ) + except (aiohttp.ClientError, TimeoutError, HassioAPIError) as err: + _LOGGER.warning("Failed to read hardware settings", exc_info=err) + return self.async_abort(reason="read_hw_settings_error") + + schema = self.add_suggested_values_to_schema( + STEP_HW_SETTINGS_SCHEMA, self._hw_settings + ) + + return self.async_show_form(step_id="hardware_settings", data_schema=schema) diff --git a/homeassistant/components/homeassistant_green/hardware.py b/homeassistant/components/homeassistant_green/hardware.py index 2b5268f8d03..c7b1641c09c 100644 --- a/homeassistant/components/homeassistant_green/hardware.py +++ b/homeassistant/components/homeassistant_green/hardware.py @@ -9,6 +9,7 @@ from homeassistant.exceptions import HomeAssistantError from .const import DOMAIN BOARD_NAME = "Home Assistant Green" +DOCUMENTATION_URL = "https://green.home-assistant.io/documentation/" MANUFACTURER = "homeassistant" MODEL = "green" @@ -39,6 +40,6 @@ def async_info(hass: HomeAssistant) -> list[HardwareInfo]: config_entries=config_entries, dongle=None, name=BOARD_NAME, - url=None, + url=DOCUMENTATION_URL, ) ] diff --git a/homeassistant/components/homeassistant_green/strings.json b/homeassistant/components/homeassistant_green/strings.json new file mode 100644 index 00000000000..9066ca64e5c --- /dev/null +++ b/homeassistant/components/homeassistant_green/strings.json @@ -0,0 +1,28 @@ +{ + "options": { + "step": { + "hardware_settings": { + "title": "Configure hardware settings", + "data": { + "activity_led": "Green: activity LED", + "power_led": "White: power LED", + "system_health_led": "Yellow: system health LED" + } + }, + "reboot_menu": { + "title": "Reboot required", + "description": "The settings have changed, but the new settings will not take effect until the system is rebooted", + "menu_options": { + "reboot_later": "Reboot manually later", + "reboot_now": "Reboot now" + } + } + }, + "abort": { + "not_hassio": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::not_hassio%]", + "read_hw_settings_error": "Failed to read hardware settings", + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", + "write_hw_settings_error": "Failed to write hardware settings" + } + } +} diff --git a/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py b/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py index b4723a88742..40cf1e18b0e 100644 --- a/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py +++ b/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py @@ -8,7 +8,6 @@ import dataclasses import logging from typing import Any, Protocol -import async_timeout import voluptuous as vol import yarl @@ -74,7 +73,7 @@ class WaitingAddonManager(AddonManager): async def async_wait_until_addon_state(self, *states: AddonState) -> None: """Poll an addon's info until it is in a specific state.""" - async with async_timeout.timeout(ADDON_INFO_POLL_TIMEOUT): + async with asyncio.timeout(ADDON_INFO_POLL_TIMEOUT): while True: try: info = await self.async_get_addon_info() @@ -886,7 +885,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow, ABC): async def check_multi_pan_addon(hass: HomeAssistant) -> None: - """Check the multi-PAN addon state, and start it if installed but not started. + """Check the multiprotocol addon state, and start it if installed but not started. Does nothing if Hass.io is not loaded. Raises on error or if the add-on is installed but not started. diff --git a/homeassistant/components/homeassistant_sky_connect/__init__.py b/homeassistant/components/homeassistant_sky_connect/__init__.py index 5f17069f5d5..218e0c3e88d 100644 --- a/homeassistant/components/homeassistant_sky_connect/__init__.py +++ b/homeassistant/components/homeassistant_sky_connect/__init__.py @@ -45,7 +45,7 @@ async def _async_usb_scan_done(hass: HomeAssistant, entry: ConfigEntry) -> None: return hw_discovery_data = { - "name": "SkyConnect Multi-PAN", + "name": "SkyConnect Multiprotocol", "port": { "path": get_zigbee_socket(), }, diff --git a/homeassistant/components/homeassistant_sky_connect/config_flow.py b/homeassistant/components/homeassistant_sky_connect/config_flow.py index 5ac44f3f290..fce731777b1 100644 --- a/homeassistant/components/homeassistant_sky_connect/config_flow.py +++ b/homeassistant/components/homeassistant_sky_connect/config_flow.py @@ -76,7 +76,7 @@ class HomeAssistantSkyConnectOptionsFlow(silabs_multiprotocol_addon.OptionsFlowH def _zha_name(self) -> str: """Return the ZHA name.""" - return "SkyConnect Multi-PAN" + return "SkyConnect Multiprotocol" def _hardware_name(self) -> str: """Return the name of the hardware.""" diff --git a/homeassistant/components/homeassistant_sky_connect/strings.json b/homeassistant/components/homeassistant_sky_connect/strings.json index 58fc0180743..2ed0026a48c 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/__init__.py b/homeassistant/components/homeassistant_yellow/__init__.py index 30015d1bae4..b61e01061c3 100644 --- a/homeassistant/components/homeassistant_yellow/__init__.py +++ b/homeassistant/components/homeassistant_yellow/__init__.py @@ -35,7 +35,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hw_discovery_data = ZHA_HW_DISCOVERY_DATA else: hw_discovery_data = { - "name": "Yellow Multi-PAN", + "name": "Yellow Multiprotocol", "port": { "path": get_zigbee_socket(), }, diff --git a/homeassistant/components/homeassistant_yellow/config_flow.py b/homeassistant/components/homeassistant_yellow/config_flow.py index 8be7b8a4ff7..667b8f3d97a 100644 --- a/homeassistant/components/homeassistant_yellow/config_flow.py +++ b/homeassistant/components/homeassistant_yellow/config_flow.py @@ -153,7 +153,7 @@ class HomeAssistantYellowOptionsFlow(silabs_multiprotocol_addon.OptionsFlowHandl def _zha_name(self) -> str: """Return the ZHA name.""" - return "Yellow Multi-PAN" + return "Yellow Multiprotocol" def _hardware_name(self) -> str: """Return the name of the hardware.""" diff --git a/homeassistant/components/homeassistant_yellow/strings.json b/homeassistant/components/homeassistant_yellow/strings.json index e5250f163ce..894d799d073 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/__init__.py b/homeassistant/components/homekit/__init__.py index 514c218b101..bb4efb7db6c 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -856,8 +856,7 @@ class HomeKit: connection = (dr.CONNECTION_NETWORK_MAC, formatted_mac) identifier = (DOMAIN, self._entry_id, BRIDGE_SERIAL_NUMBER) self._async_purge_old_bridges(dev_reg, identifier, connection) - is_accessory_mode = self._homekit_mode == HOMEKIT_MODE_ACCESSORY - hk_mode_name = "Accessory" if is_accessory_mode else "Bridge" + accessory_type = type(self.driver.accessory).__name__ dev_reg.async_get_or_create( config_entry_id=self._entry_id, identifiers={ @@ -866,7 +865,7 @@ class HomeKit: connections={connection}, manufacturer=MANUFACTURER, name=accessory_friendly_name(self._entry_title, self.driver.accessory), - model=f"HomeKit {hk_mode_name}", + model=accessory_type, entry_type=dr.DeviceEntryType.SERVICE, ) diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index f88047795ca..5a1e9bc1ea2 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -183,7 +183,9 @@ def get_accessory( # noqa: C901 device_class = state.attributes.get(ATTR_DEVICE_CLASS) feature_list = config.get(CONF_FEATURE_LIST, []) - if device_class == MediaPlayerDeviceClass.TV: + if device_class == MediaPlayerDeviceClass.RECEIVER: + a_type = "ReceiverMediaPlayer" + elif device_class == MediaPlayerDeviceClass.TV: a_type = "TelevisionMediaPlayer" elif validate_media_player_features(state, feature_list): a_type = "MediaPlayer" @@ -274,7 +276,7 @@ class HomeAccessory(Accessory): # type: ignore[misc] aid: int, config: dict, *args: Any, - category: str = CATEGORY_OTHER, + category: int = CATEGORY_OTHER, device_id: str | None = None, **kwargs: Any, ) -> None: @@ -463,7 +465,9 @@ class HomeAccessory(Accessory): # type: ignore[misc] def async_update_state_callback(self, new_state: State | None) -> None: """Handle state change listener callback.""" _LOGGER.debug("New_state: %s", new_state) - if new_state is None: + # HomeKit handles unavailable state via the available property + # so we should not propagate it here + if new_state is None or new_state.state == STATE_UNAVAILABLE: return battery_state = None battery_charging_state = None diff --git a/homeassistant/components/homekit/config_flow.py b/homeassistant/components/homekit/config_flow.py index 3747af3edc7..c43093d92b4 100644 --- a/homeassistant/components/homekit/config_flow.py +++ b/homeassistant/components/homekit/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations 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/const.py b/homeassistant/components/homekit/const.py index 81dbf4f7e2e..bb5ae1ffd1c 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -115,6 +115,9 @@ TYPE_SPRINKLER = "sprinkler" TYPE_SWITCH = "switch" TYPE_VALVE = "valve" +# #### Categories #### +CATEGORY_RECEIVER = 34 + # #### Services #### SERV_ACCESSORY_INFO = "AccessoryInformation" SERV_AIR_QUALITY_SENSOR = "AirQualitySensor" diff --git a/homeassistant/components/homekit/strings.json b/homeassistant/components/homekit/strings.json index f57536263ca..30ecfba569e 100644 --- a/homeassistant/components/homekit/strings.json +++ b/homeassistant/components/homekit/strings.json @@ -11,7 +11,7 @@ "include_exclude_mode": "Inclusion Mode", "domains": "[%key:component::homekit::config::step::user::data::include_domains%]" }, - "description": "HomeKit can be configured expose a bridge or a single accessory. In accessory mode, only a single entity can be used. Accessory mode is required for media players with the TV device class to function properly. Entities in the \u201cDomains to include\u201d will be included to HomeKit. You will be able to select which entities to include or exclude from this list on the next screen.", + "description": "HomeKit can be configured expose a bridge or a single accessory. In accessory mode, only a single entity can be used. Accessory mode is required for media players with the TV or RECEIVER device class to function properly. Entities in the \u201cDomains to include\u201d will be included to HomeKit. You will be able to select which entities to include or exclude from this list on the next screen.", "title": "Select mode and domains." }, "accessory": { @@ -57,7 +57,7 @@ "data": { "include_domains": "Domains to include" }, - "description": "Choose the domains to be included. All supported entities in the domain will be included except for categorized entities. A separate HomeKit instance in accessory mode will be created for each tv media player, activity based remote, lock, and camera.", + "description": "Choose the domains to be included. All supported entities in the domain will be included except for categorized entities. A separate HomeKit instance in accessory mode will be created for each tv/receiver media player, activity based remote, lock, and camera.", "title": "Select domains to be included" }, "pairing": { diff --git a/homeassistant/components/homekit/type_cameras.py b/homeassistant/components/homekit/type_cameras.py index 62d27245a1c..4c7ba5a7841 100644 --- a/homeassistant/components/homekit/type_cameras.py +++ b/homeassistant/components/homekit/type_cameras.py @@ -79,7 +79,7 @@ VIDEO_OUTPUT = ( "-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 @@ AUDIO_OUTPUT = ( "-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/type_media_players.py b/homeassistant/components/homekit/type_media_players.py index eae7ed2742a..da7fdceede3 100644 --- a/homeassistant/components/homekit/type_media_players.py +++ b/homeassistant/components/homekit/type_media_players.py @@ -1,5 +1,6 @@ """Class to hold all media player accessories.""" import logging +from typing import Any from pyhap.const import CATEGORY_SWITCH @@ -36,6 +37,7 @@ from homeassistant.core import callback from .accessories import TYPES, HomeAccessory from .const import ( ATTR_KEY_NAME, + CATEGORY_RECEIVER, CHAR_ACTIVE, CHAR_MUTE, CHAR_NAME, @@ -218,18 +220,20 @@ class MediaPlayer(HomeAccessory): class TelevisionMediaPlayer(RemoteInputSelectAccessory): """Generate a Television Media Player accessory.""" - def __init__(self, *args): + def __init__(self, *args: Any, **kwargs: Any) -> None: """Initialize a Television Media Player accessory object.""" super().__init__( MediaPlayerEntityFeature.SELECT_SOURCE, ATTR_INPUT_SOURCE, ATTR_INPUT_SOURCE_LIST, *args, + **kwargs, ) state = self.hass.states.get(self.entity_id) + assert state features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - self.chars_speaker = [] + self.chars_speaker: list[str] = [] self._supports_play_pause = features & ( MediaPlayerEntityFeature.PLAY | MediaPlayerEntityFeature.PAUSE @@ -358,3 +362,17 @@ class TelevisionMediaPlayer(RemoteInputSelectAccessory): self.char_mute.set_value(current_mute_state) self._async_update_input_state(hk_state, new_state) + + +@TYPES.register("ReceiverMediaPlayer") +class ReceiverMediaPlayer(TelevisionMediaPlayer): + """Generate a Receiver Media Player accessory. + + For HomeKit, a Receiver Media Player is exactly the same as a + Television Media Player except it has a different category + which will tell HomeKit how to render the device. + """ + + def __init__(self, *args: Any) -> None: + """Initialize a Receiver Media Player accessory object.""" + super().__init__(*args, category=CATEGORY_RECEIVER) diff --git a/homeassistant/components/homekit/type_remotes.py b/homeassistant/components/homekit/type_remotes.py index 69441b5ebe1..e440a5b3ac0 100644 --- a/homeassistant/components/homekit/type_remotes.py +++ b/homeassistant/components/homekit/type_remotes.py @@ -1,6 +1,7 @@ """Class to hold remote accessories.""" from abc import ABC, abstractmethod import logging +from typing import Any from pyhap.const import CATEGORY_TELEVISION @@ -80,19 +81,21 @@ class RemoteInputSelectAccessory(HomeAccessory, ABC): def __init__( self, - required_feature, - source_key, - source_list_key, - *args, - **kwargs, - ): + required_feature: int, + source_key: str, + source_list_key: str, + *args: Any, + category: int = CATEGORY_TELEVISION, + **kwargs: Any, + ) -> None: """Initialize a InputSelect accessory object.""" - super().__init__(*args, category=CATEGORY_TELEVISION, **kwargs) + super().__init__(*args, category=category, **kwargs) state = self.hass.states.get(self.entity_id) + assert state features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - self._mapped_sources_list = [] - self._mapped_sources = {} + self._mapped_sources_list: list[str] = [] + self._mapped_sources: dict[str, str] = {} self.source_key = source_key self.source_list_key = source_list_key self.sources = [] diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py index 8287c2b7845..151b97f2cda 100644 --- a/homeassistant/components/homekit/util.py +++ b/homeassistant/components/homekit/util.py @@ -614,7 +614,8 @@ def state_needs_accessory_mode(state: State) -> bool: return ( state.domain == MEDIA_PLAYER_DOMAIN - and state.attributes.get(ATTR_DEVICE_CLASS) == MediaPlayerDeviceClass.TV + and state.attributes.get(ATTR_DEVICE_CLASS) + in (MediaPlayerDeviceClass.TV, MediaPlayerDeviceClass.RECEIVER) or state.domain == REMOTE_DOMAIN and state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) & RemoteEntityFeature.ACTIVITY diff --git a/homeassistant/components/homekit_controller/config_flow.py b/homeassistant/components/homekit_controller/config_flow.py index 988adbd87a7..088747d39ff 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 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): 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 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): # 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 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): "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 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): # 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 3e5fd4655d6..348dd5e7ccf 100644 --- a/homeassistant/components/homekit_controller/connection.py +++ b/homeassistant/components/homekit_controller/connection.py @@ -5,6 +5,7 @@ import asyncio 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 @@ class HKDevice: # 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/const.py b/homeassistant/components/homekit_controller/const.py index cde9aa732c3..f60dc669968 100644 --- a/homeassistant/components/homekit_controller/const.py +++ b/homeassistant/components/homekit_controller/const.py @@ -77,6 +77,9 @@ CHARACTERISTIC_PLATFORMS = { CharacteristicsTypes.VENDOR_EVE_ENERGY_WATT: "sensor", CharacteristicsTypes.VENDOR_EVE_DEGREE_AIR_PRESSURE: "sensor", CharacteristicsTypes.VENDOR_EVE_DEGREE_ELEVATION: "number", + CharacteristicsTypes.VENDOR_EVE_MOTION_DURATION: "number", + CharacteristicsTypes.VENDOR_EVE_MOTION_SENSITIVITY: "number", + CharacteristicsTypes.VENDOR_EVE_THERMO_VALVE_POSITION: "sensor", CharacteristicsTypes.VENDOR_HAA_SETUP: "button", CharacteristicsTypes.VENDOR_HAA_UPDATE: "button", CharacteristicsTypes.VENDOR_KOOGEEK_REALTIME_ENERGY: "sensor", @@ -101,6 +104,7 @@ CHARACTERISTIC_PLATFORMS = { CharacteristicsTypes.MUTE: "switch", CharacteristicsTypes.FILTER_LIFE_LEVEL: "sensor", CharacteristicsTypes.VENDOR_AIRVERSA_SLEEP_MODE: "switch", + CharacteristicsTypes.TEMPERATURE_UNITS: "select", } STARTUP_EXCEPTIONS = ( diff --git a/homeassistant/components/homekit_controller/cover.py b/homeassistant/components/homekit_controller/cover.py index 73eb699007c..0f4af988c14 100644 --- a/homeassistant/components/homekit_controller/cover.py +++ b/homeassistant/components/homekit_controller/cover.py @@ -299,8 +299,14 @@ class HomeKitWindowCover(HomeKitEntity, CoverEntity): return {"obstruction-detected": obstruction_detected} +class HomeKitWindow(HomeKitWindowCover): + """Representation of a HomeKit Window.""" + + _attr_device_class = CoverDeviceClass.WINDOW + + ENTITY_TYPES = { ServicesTypes.GARAGE_DOOR_OPENER: HomeKitGarageDoorCover, ServicesTypes.WINDOW_COVERING: HomeKitWindowCover, - ServicesTypes.WINDOW: HomeKitWindowCover, + ServicesTypes.WINDOW: HomeKitWindow, } diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index 9567ff83cea..5687cd4dba3 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.2"], + "requirements": ["aiohomekit==3.0.5"], "zeroconf": ["_hap._tcp.local.", "_hap._udp.local."] } diff --git a/homeassistant/components/homekit_controller/number.py b/homeassistant/components/homekit_controller/number.py index b44aed16143..c453efb8219 100644 --- a/homeassistant/components/homekit_controller/number.py +++ b/homeassistant/components/homekit_controller/number.py @@ -49,6 +49,18 @@ NUMBER_ENTITIES: dict[str, NumberEntityDescription] = { icon="mdi:volume-high", entity_category=EntityCategory.CONFIG, ), + CharacteristicsTypes.VENDOR_EVE_MOTION_DURATION: NumberEntityDescription( + key=CharacteristicsTypes.VENDOR_EVE_MOTION_DURATION, + name="Duration", + icon="mdi:timer", + entity_category=EntityCategory.CONFIG, + ), + CharacteristicsTypes.VENDOR_EVE_MOTION_SENSITIVITY: NumberEntityDescription( + key=CharacteristicsTypes.VENDOR_EVE_MOTION_SENSITIVITY, + name="Sensitivity", + icon="mdi:knob", + entity_category=EntityCategory.CONFIG, + ), } diff --git a/homeassistant/components/homekit_controller/select.py b/homeassistant/components/homekit_controller/select.py index 76067aea061..09bb57923c6 100644 --- a/homeassistant/components/homekit_controller/select.py +++ b/homeassistant/components/homekit_controller/select.py @@ -1,18 +1,54 @@ """Support for Homekit select entities.""" from __future__ import annotations -from aiohomekit.model.characteristics import Characteristic, CharacteristicsTypes +from dataclasses import dataclass +from enum import IntEnum -from homeassistant.components.select import SelectEntity +from aiohomekit.model.characteristics import Characteristic, CharacteristicsTypes +from aiohomekit.model.characteristics.const import TemperatureDisplayUnits + +from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.const import EntityCategory, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType from . import KNOWN_DEVICES from .connection import HKDevice from .entity import CharacteristicEntity + +@dataclass +class HomeKitSelectEntityDescriptionRequired: + """Required fields for HomeKitSelectEntityDescription.""" + + choices: dict[str, IntEnum] + + +@dataclass +class HomeKitSelectEntityDescription( + SelectEntityDescription, HomeKitSelectEntityDescriptionRequired +): + """A generic description of a select entity backed by a single characteristic.""" + + name: str | None = None + + +SELECT_ENTITIES: dict[str, HomeKitSelectEntityDescription] = { + CharacteristicsTypes.TEMPERATURE_UNITS: HomeKitSelectEntityDescription( + key="temperature_display_units", + translation_key="temperature_display_units", + name="Temperature Display Units", + icon="mdi:thermometer", + entity_category=EntityCategory.CONFIG, + choices={ + "celsius": TemperatureDisplayUnits.CELSIUS, + "fahrenheit": TemperatureDisplayUnits.FAHRENHEIT, + }, + ), +} + _ECOBEE_MODE_TO_TEXT = { 0: "home", 1: "sleep", @@ -21,7 +57,58 @@ _ECOBEE_MODE_TO_TEXT = { _ECOBEE_MODE_TO_NUMBERS = {v: k for (k, v) in _ECOBEE_MODE_TO_TEXT.items()} -class EcobeeModeSelect(CharacteristicEntity, SelectEntity): +class BaseHomeKitSelect(CharacteristicEntity, SelectEntity): + """Base entity for select entities backed by a single characteristics.""" + + +class HomeKitSelect(BaseHomeKitSelect): + """Representation of a select control on a homekit accessory.""" + + entity_description: HomeKitSelectEntityDescription + + def __init__( + self, + conn: HKDevice, + info: ConfigType, + char: Characteristic, + description: HomeKitSelectEntityDescription, + ) -> None: + """Initialise a HomeKit select control.""" + self.entity_description = description + + self._choice_to_enum = self.entity_description.choices + self._enum_to_choice = { + v: k for (k, v) in self.entity_description.choices.items() + } + + self._attr_options = list(self.entity_description.choices.keys()) + + super().__init__(conn, info, char) + + def get_characteristic_types(self) -> list[str]: + """Define the homekit characteristics the entity cares about.""" + return [self._char.type] + + @property + def name(self) -> str | None: + """Return the name of the device if any.""" + if name := self.accessory.name: + return f"{name} {self.entity_description.name}" + return self.entity_description.name + + @property + def current_option(self) -> str | None: + """Return the current selected option.""" + return self._enum_to_choice.get(self._char.value) + + async def async_select_option(self, option: str) -> None: + """Set the current option.""" + await self.async_put_characteristics( + {self._char.type: self._choice_to_enum[option]} + ) + + +class EcobeeModeSelect(BaseHomeKitSelect): """Represents a ecobee mode select entity.""" _attr_options = ["home", "sleep", "away"] @@ -64,14 +151,23 @@ async def async_setup_entry( @callback def async_add_characteristic(char: Characteristic) -> bool: - if char.type == CharacteristicsTypes.VENDOR_ECOBEE_CURRENT_MODE: - info = {"aid": char.service.accessory.aid, "iid": char.service.iid} - entity = EcobeeModeSelect(conn, info, char) + entities: list[BaseHomeKitSelect] = [] + info = {"aid": char.service.accessory.aid, "iid": char.service.iid} + + if description := SELECT_ENTITIES.get(char.type): + entities.append(HomeKitSelect(conn, info, char, description)) + elif char.type == CharacteristicsTypes.VENDOR_ECOBEE_CURRENT_MODE: + entities.append(EcobeeModeSelect(conn, info, char)) + + if not entities: + return False + + for entity in entities: conn.async_migrate_unique_id( entity.old_unique_id, entity.unique_id, Platform.SELECT ) - async_add_entities([entity]) - return True - return False + + async_add_entities(entities) + return True conn.add_char_factory(async_add_characteristic) diff --git a/homeassistant/components/homekit_controller/sensor.py b/homeassistant/components/homekit_controller/sensor.py index 5803b8aa839..0f481c5c7ee 100644 --- a/homeassistant/components/homekit_controller/sensor.py +++ b/homeassistant/components/homekit_controller/sensor.py @@ -337,6 +337,14 @@ SIMPLE_SENSOR: dict[str, HomeKitSensorEntityDescription] = { state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, ), + CharacteristicsTypes.VENDOR_EVE_THERMO_VALVE_POSITION: HomeKitSensorEntityDescription( + key=CharacteristicsTypes.VENDOR_EVE_THERMO_VALVE_POSITION, + name="Valve position", + icon="mdi:pipe-valve", + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + ), } diff --git a/homeassistant/components/homekit_controller/strings.json b/homeassistant/components/homekit_controller/strings.json index 901378c8cb9..bc61b6fd42e 100644 --- a/homeassistant/components/homekit_controller/strings.json +++ b/homeassistant/components/homekit_controller/strings.json @@ -102,6 +102,12 @@ "home": "[%key:common::state::home%]", "sleep": "Sleep" } + }, + "temperature_display_units": { + "state": { + "celsius": "Celsius", + "fahrenheit": "Fahrenheit" + } } }, "sensor": { diff --git a/homeassistant/components/homematicip_cloud/binary_sensor.py b/homeassistant/components/homematicip_cloud/binary_sensor.py index 6730f722685..2afe803e1eb 100644 --- a/homeassistant/components/homematicip_cloud/binary_sensor.py +++ b/homeassistant/components/homematicip_cloud/binary_sensor.py @@ -194,10 +194,7 @@ class HomematicipCloudConnectionSensor(HomematicipGenericEntity, BinarySensorEnt 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 @@ class HomematicipMultiContactInterface(HomematicipGenericEntity, BinarySensorEnt 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 @@ class HomematicipContactInterface(HomematicipMultiContactInterface, BinarySensor 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 @@ class HomematicipShutterContact(HomematicipMultiContactInterface, BinarySensorEn 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 @@ class HomematicipShutterContact(HomematicipMultiContactInterface, BinarySensorEn 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 @@ class HomematicipMotionDetector(HomematicipGenericEntity, BinarySensorEntity): 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 @@ class HomematicipPresenceDetector(HomematicipGenericEntity, BinarySensorEntity): 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 @@ class HomematicipSmokeDetector(HomematicipGenericEntity, BinarySensorEntity): 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 @@ class HomematicipStormSensor(HomematicipGenericEntity, BinarySensorEntity): 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 @@ class HomematicipRainSensor(HomematicipGenericEntity, BinarySensorEntity): 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 @@ class HomematicipSunshineSensor(HomematicipGenericEntity, BinarySensorEntity): 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 @@ class HomematicipPluggableMainsFailureSurveillanceSensor( 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 e5007b5a15f..f5a9919579c 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 @@ class HomematicipBlindModule(HomematicipGenericEntity, CoverEntity): class HomematicipMultiCoverShutter(HomematicipGenericEntity, CoverEntity): """Representation of the HomematicIP cover shutter.""" + _attr_device_class = CoverDeviceClass.SHUTTER + def __init__( self, hap: HomematicipHAP, @@ -161,11 +160,6 @@ class HomematicipMultiCoverShutter(HomematicipGenericEntity, CoverEntity): 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 @@ class HomematicipCoverSlats(HomematicipMultiCoverSlats, CoverEntity): 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 @@ class HomematicipGarageDoorModule(HomematicipGenericEntity, CoverEntity): } 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 @@ class HomematicipGarageDoorModule(HomematicipGenericEntity, CoverEntity): 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 1b86e36b826..c3d14b7d383 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 e913e1125f1..573f291d557 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 @@ class HomematicipWeatherSensor(HomematicipGenericEntity, WeatherEntity): """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 @@ class HomematicipHomeWeather(HomematicipGenericEntity, WeatherEntity): """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 36b9631c801..8930ec90ebf 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 b23df9f1f4b..63d05135d5d 100644 --- a/homeassistant/components/honeywell/climate.py +++ b/homeassistant/components/honeywell/climate.py @@ -27,6 +27,8 @@ from homeassistant.components.climate import ( 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 @@ class HoneywellUSThermostat(ClimateEntity): 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 @@ class HoneywellUSThermostat(ClimateEntity): 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 @@ class HoneywellUSThermostat(ClimateEntity): 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 @@ class HoneywellUSThermostat(ClimateEntity): 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 @@ class HoneywellUSThermostat(ClimateEntity): # 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 @@ class HoneywellUSThermostat(ClimateEntity): 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 00000000000..4aebfc4c905 --- /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 dec1b9485b6..bce425adbdb 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 f21f084a544..929ca0193af 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 9966b9cc5f5..2d96a4e0426 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 @@ class HuaweiLteBaseBinarySensor(HuaweiLteBaseEntityWithDevice, BinarySensorEntit 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 @@ class HuaweiLteMobileConnectionBinarySensor(HuaweiLteBaseBinarySensor): """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 b8833b24d92..665c96e4888 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 133b569c751..a4321bfd93f 100644 --- a/homeassistant/components/huawei_lte/sensor.py +++ b/homeassistant/components/huawei_lte/sensor.py @@ -724,9 +724,9 @@ class HuaweiLteSensor(HuaweiLteBaseEntityWithDevice, SensorEntity): 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 2fe064d6300..f75cf14e89b 100644 --- a/homeassistant/components/huawei_lte/switch.py +++ b/homeassistant/components/huawei_lte/switch.py @@ -69,7 +69,7 @@ class HuaweiLteBaseSwitch(HuaweiLteBaseEntityWithDevice, SwitchEntity): 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 9c8dda94c94..0957329abb0 100644 --- a/homeassistant/components/hue/config_flow.py +++ b/homeassistant/components/hue/config_flow.py @@ -22,7 +22,6 @@ from homeassistant.helpers import ( 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 @@ class HueFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): 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/manifest.json b/homeassistant/components/hue/manifest.json index e55bd2782df..4cd6ca143cb 100644 --- a/homeassistant/components/hue/manifest.json +++ b/homeassistant/components/hue/manifest.json @@ -11,6 +11,6 @@ "iot_class": "local_push", "loggers": ["aiohue"], "quality_scale": "platinum", - "requirements": ["aiohue==4.6.2"], + "requirements": ["aiohue==4.7.0"], "zeroconf": ["_hue._tcp.local."] } diff --git a/homeassistant/components/hue/strings.json b/homeassistant/components/hue/strings.json index 6d65abc8d5f..1224abb240e 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/binary_sensor.py b/homeassistant/components/hue/v2/binary_sensor.py index 0a8f50b8b7a..1eded0429b8 100644 --- a/homeassistant/components/hue/v2/binary_sensor.py +++ b/homeassistant/components/hue/v2/binary_sensor.py @@ -1,7 +1,7 @@ """Support for Hue binary sensors.""" from __future__ import annotations -from typing import Any, TypeAlias +from typing import TypeAlias from aiohue.v2 import HueBridgeV2 from aiohue.v2.controllers.config import ( @@ -9,9 +9,17 @@ from aiohue.v2.controllers.config import ( EntertainmentConfigurationController, ) from aiohue.v2.controllers.events import EventType -from aiohue.v2.controllers.sensors import MotionController +from aiohue.v2.controllers.sensors import ( + CameraMotionController, + ContactController, + MotionController, + TamperController, +) +from aiohue.v2.models.camera_motion import CameraMotion +from aiohue.v2.models.contact import Contact, ContactState from aiohue.v2.models.entertainment_configuration import EntertainmentStatus from aiohue.v2.models.motion import Motion +from aiohue.v2.models.tamper import Tamper, TamperState from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -25,8 +33,16 @@ from ..bridge import HueBridge from ..const import DOMAIN from .entity import HueBaseEntity -SensorType: TypeAlias = Motion | EntertainmentConfiguration -ControllerType: TypeAlias = MotionController | EntertainmentConfigurationController +SensorType: TypeAlias = ( + CameraMotion | Contact | Motion | EntertainmentConfiguration | Tamper +) +ControllerType: TypeAlias = ( + CameraMotionController + | ContactController + | MotionController + | EntertainmentConfigurationController + | TamperController +) async def async_setup_entry( @@ -57,8 +73,11 @@ async def async_setup_entry( ) # setup for each binary-sensor-type hue resource + register_items(api.sensors.camera_motion, HueMotionSensor) register_items(api.sensors.motion, HueMotionSensor) register_items(api.config.entertainment_configuration, HueEntertainmentActiveSensor) + register_items(api.sensors.contact, HueContactSensor) + register_items(api.sensors.tamper, HueTamperSensor) class HueBinarySensorBase(HueBaseEntity, BinarySensorEntity): @@ -87,12 +106,7 @@ class HueMotionSensor(HueBinarySensorBase): if not self.resource.enabled: # Force None (unknown) if the sensor is set to disabled in Hue return None - return self.resource.motion.motion - - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return the optional state attributes.""" - return {"motion_valid": self.resource.motion.motion_valid} + return self.resource.motion.value class HueEntertainmentActiveSensor(HueBinarySensorBase): @@ -110,3 +124,30 @@ class HueEntertainmentActiveSensor(HueBinarySensorBase): """Return sensor name.""" type_title = self.resource.type.value.replace("_", " ").title() return f"{self.resource.metadata.name}: {type_title}" + + +class HueContactSensor(HueBinarySensorBase): + """Representation of a Hue Contact sensor.""" + + _attr_device_class = BinarySensorDeviceClass.OPENING + + @property + def is_on(self) -> bool | None: + """Return true if the binary sensor is on.""" + if not self.resource.enabled: + # Force None (unknown) if the sensor is set to disabled in Hue + return None + return self.resource.contact_report.state != ContactState.CONTACT + + +class HueTamperSensor(HueBinarySensorBase): + """Representation of a Hue Tamper sensor.""" + + _attr_device_class = BinarySensorDeviceClass.TAMPER + + @property + def is_on(self) -> bool | None: + """Return true if the binary sensor is on.""" + if not self.resource.tamper_reports: + return False + return self.resource.tamper_reports[0].state == TamperState.TAMPERED diff --git a/homeassistant/components/hue/v2/sensor.py b/homeassistant/components/hue/v2/sensor.py index dcdae0a3294..4bfb727b917 100644 --- a/homeassistant/components/hue/v2/sensor.py +++ b/homeassistant/components/hue/v2/sensor.py @@ -100,12 +100,7 @@ class HueTemperatureSensor(HueSensorBase): @property def native_value(self) -> float: """Return the value reported by the sensor.""" - return round(self.resource.temperature.temperature, 1) - - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return the optional state attributes.""" - return {"temperature_valid": self.resource.temperature.temperature_valid} + return round(self.resource.temperature.value, 1) class HueLightLevelSensor(HueSensorBase): @@ -122,14 +117,13 @@ class HueLightLevelSensor(HueSensorBase): # scale used because the human eye adjusts to light levels and small # changes at low lux levels are more noticeable than at high lux # levels. - return int(10 ** ((self.resource.light.light_level - 1) / 10000)) + return int(10 ** ((self.resource.light.value - 1) / 10000)) @property def extra_state_attributes(self) -> dict[str, Any]: """Return the optional state attributes.""" return { - "light_level": self.resource.light.light_level, - "light_level_valid": self.resource.light.light_level_valid, + "light_level": self.resource.light.value, } @@ -149,6 +143,8 @@ class HueBatterySensor(HueSensorBase): @property def extra_state_attributes(self) -> dict[str, Any]: """Return the optional state attributes.""" + if self.resource.power_state.battery_state is None: + return {} return {"battery_state": self.resource.power_state.battery_state.value} @@ -156,6 +152,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 a525c626f14..47745c53394 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 53df96605d6..00000000000 --- 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 19a9a8eab77..1cdad10f2fb 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 833c1812ddb..18fe1cd0a69 100644 --- a/homeassistant/components/hunterdouglas_powerview/cover.py +++ b/homeassistant/components/hunterdouglas_powerview/cover.py @@ -311,6 +311,7 @@ class PowerViewShadeBase(ShadeEntity, CoverEntity): 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 ba1221a25ac..0c09917d35b 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 825ca140f14..330e5dddfa5 100644 --- a/homeassistant/components/hunterdouglas_powerview/sensor.py +++ b/homeassistant/components/hunterdouglas_powerview/sensor.py @@ -136,6 +136,7 @@ class PowerViewSensor(ShadeEntity, SensorEntity): """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 513c8dbd8b0..0ec08e9c791 100644 --- a/homeassistant/components/hvv_departures/binary_sensor.py +++ b/homeassistant/components/hvv_departures/binary_sensor.py @@ -191,16 +191,3 @@ class HvvDepartureBinarySensor(CoordinatorEntity, BinarySensorEntity): for k, v in self.coordinator.data[self.idx]["attributes"].items() if v is not None } - - async def async_added_to_hass(self) -> None: - """When entity is added to hass.""" - self.async_on_remove( - self.coordinator.async_add_listener(self.async_write_ha_state) - ) - - async def async_update(self) -> None: - """Update the entity. - - Only used by the generic entity update service. - """ - await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/hydrawise/__init__.py b/homeassistant/components/hydrawise/__init__.py index 6d9f2747847..bc3c62cfb9f 100644 --- a/homeassistant/components/hydrawise/__init__.py +++ b/homeassistant/components/hydrawise/__init__.py @@ -1,17 +1,23 @@ """Support for Hydrawise cloud.""" -from pydrawise.legacy import LegacyHydrawise +from pydrawise import legacy from requests.exceptions import ConnectTimeout, HTTPError import voluptuous as vol -from homeassistant.components import persistent_notification -from homeassistant.const import CONF_ACCESS_TOKEN, CONF_SCAN_INTERVAL +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import ( + CONF_ACCESS_TOKEN, + CONF_API_KEY, + CONF_SCAN_INTERVAL, + Platform, +) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType -from .const import DOMAIN, LOGGER, NOTIFICATION_ID, NOTIFICATION_TITLE, SCAN_INTERVAL +from .const import DOMAIN, LOGGER, SCAN_INTERVAL from .coordinator import HydrawiseDataUpdateCoordinator CONFIG_SCHEMA = vol.Schema( @@ -26,37 +32,51 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) +PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH] + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Hunter Hydrawise component.""" - conf = config[DOMAIN] - access_token = conf[CONF_ACCESS_TOKEN] - scan_interval = conf.get(CONF_SCAN_INTERVAL) - - try: - hydrawise = await hass.async_add_executor_job(LegacyHydrawise, access_token) - except (ConnectTimeout, HTTPError) as ex: - LOGGER.error("Unable to connect to Hydrawise cloud service: %s", str(ex)) - _show_failure_notification(hass, str(ex)) - return False - - if not hydrawise.current_controller: - LOGGER.error("Failed to fetch Hydrawise data") - _show_failure_notification(hass, "Failed to fetch Hydrawise data.") - return False - - hass.data[DOMAIN] = HydrawiseDataUpdateCoordinator(hass, hydrawise, scan_interval) - - # NOTE: We don't need to call async_config_entry_first_refresh() because - # data is fetched when the Hydrawiser object is instantiated. + if DOMAIN not in config: + return True + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_API_KEY: config[DOMAIN][CONF_ACCESS_TOKEN]}, + ) + ) return True -def _show_failure_notification(hass: HomeAssistant, error: str) -> None: - persistent_notification.create( - hass, - f"Error: {error}
You will need to restart hass after fixing.", - title=NOTIFICATION_TITLE, - notification_id=NOTIFICATION_ID, - ) +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Set up Hydrawise from a config entry.""" + access_token = config_entry.data[CONF_API_KEY] + try: + hydrawise = await hass.async_add_executor_job( + legacy.LegacyHydrawise, access_token + ) + except (ConnectTimeout, HTTPError) as ex: + LOGGER.error("Unable to connect to Hydrawise cloud service: %s", str(ex)) + raise ConfigEntryNotReady( + f"Unable to connect to Hydrawise cloud service: {ex}" + ) from ex + + hass.data.setdefault(DOMAIN, {})[ + config_entry.entry_id + ] = HydrawiseDataUpdateCoordinator(hass, hydrawise, SCAN_INTERVAL) + if not hydrawise.controller_info or not hydrawise.controller_status: + raise ConfigEntryNotReady("Hydrawise data not loaded") + + # NOTE: We don't need to call async_config_entry_first_refresh() because + # data is fetched when the Hydrawiser object is instantiated. + await hass.config_entries.async_forward_entry_setups(config_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/hydrawise/binary_sensor.py b/homeassistant/components/hydrawise/binary_sensor.py index 9298e605791..1c40b16926d 100644 --- a/homeassistant/components/hydrawise/binary_sensor.py +++ b/homeassistant/components/hydrawise/binary_sensor.py @@ -10,6 +10,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_MONITORED_CONDITIONS from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv @@ -22,14 +23,13 @@ from .entity import HydrawiseEntity BINARY_SENSOR_STATUS = BinarySensorEntityDescription( key="status", - name="Status", device_class=BinarySensorDeviceClass.CONNECTIVITY, ) BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( BinarySensorEntityDescription( key="is_watering", - name="Watering", + translation_key="watering", device_class=BinarySensorDeviceClass.MOISTURE, ), ) @@ -38,6 +38,8 @@ BINARY_SENSOR_KEYS: list[str] = [ desc.key for desc in (BINARY_SENSOR_STATUS, *BINARY_SENSOR_TYPES) ] +# Deprecated since Home Assistant 2023.10.0 +# Can be removed completely in 2024.4.0 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Optional(CONF_MONITORED_CONDITIONS, default=BINARY_SENSOR_KEYS): vol.All( @@ -54,32 +56,40 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up a sensor for a Hydrawise device.""" - coordinator: HydrawiseDataUpdateCoordinator = hass.data[DOMAIN] - hydrawise: LegacyHydrawise = coordinator.api - monitored_conditions = config[CONF_MONITORED_CONDITIONS] + # We don't need to trigger import flow from here as it's triggered from `__init__.py` + return - entities = [] - if BINARY_SENSOR_STATUS.key in monitored_conditions: - entities.append( - HydrawiseBinarySensor( - data=hydrawise.current_controller, - coordinator=coordinator, - description=BINARY_SENSOR_STATUS, - ) + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Hydrawise binary_sensor platform.""" + coordinator: HydrawiseDataUpdateCoordinator = hass.data[DOMAIN][ + config_entry.entry_id + ] + hydrawise: LegacyHydrawise = coordinator.api + + entities = [ + HydrawiseBinarySensor( + data=hydrawise.current_controller, + coordinator=coordinator, + description=BINARY_SENSOR_STATUS, + device_id_key="controller_id", ) + ] # create a sensor for each zone for zone in hydrawise.relays: for description in BINARY_SENSOR_TYPES: - if description.key not in monitored_conditions: - continue entities.append( HydrawiseBinarySensor( data=zone, coordinator=coordinator, description=description ) ) - add_entities(entities, True) + async_add_entities(entities) class HydrawiseBinarySensor(HydrawiseEntity, BinarySensorEntity): diff --git a/homeassistant/components/hydrawise/config_flow.py b/homeassistant/components/hydrawise/config_flow.py new file mode 100644 index 00000000000..c4b37fb4a06 --- /dev/null +++ b/homeassistant/components/hydrawise/config_flow.py @@ -0,0 +1,111 @@ +"""Config flow for the Hydrawise integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +from pydrawise import legacy +from requests.exceptions import ConnectTimeout, HTTPError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_API_KEY +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN +from homeassistant.data_entry_flow import AbortFlow, FlowResult, FlowResultType +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue + +from .const import DOMAIN, LOGGER + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Hydrawise.""" + + VERSION = 1 + + async def _create_entry( + self, api_key: str, *, on_failure: Callable[[str], FlowResult] + ) -> FlowResult: + """Create the config entry.""" + try: + api = await self.hass.async_add_executor_job( + legacy.LegacyHydrawise, api_key + ) + except ConnectTimeout: + return on_failure("timeout_connect") + except HTTPError as ex: + LOGGER.error("Unable to connect to Hydrawise cloud service: %s", ex) + return on_failure("cannot_connect") + + if not api.status: + return on_failure("unknown") + + await self.async_set_unique_id(f"hydrawise-{api.customer_id}") + self._abort_if_unique_id_configured() + + return self.async_create_entry(title="Hydrawise", data={CONF_API_KEY: api_key}) + + def _import_issue(self, error_type: str) -> FlowResult: + """Create an issue about a YAML import failure.""" + async_create_issue( + self.hass, + DOMAIN, + f"deprecated_yaml_import_issue_{error_type}", + breaks_in_ha_version="2024.4.0", + is_fixable=False, + severity=IssueSeverity.ERROR, + translation_key="deprecated_yaml_import_issue", + translation_placeholders={"error_type": error_type}, + ) + return self.async_abort(reason=error_type) + + def _deprecated_yaml_issue(self) -> None: + """Create an issue about YAML deprecation.""" + 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": "Hydrawise", + }, + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial setup.""" + if user_input is not None: + api_key = user_input[CONF_API_KEY] + return await self._create_entry(api_key, on_failure=self._show_form) + return self._show_form() + + def _show_form(self, error_type: str | None = None) -> FlowResult: + errors = {} + if error_type is not None: + errors["base"] = error_type + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({vol.Required(CONF_API_KEY): str}), + errors=errors, + ) + + async def async_step_import(self, import_data: dict[str, Any]) -> FlowResult: + """Import data from YAML.""" + try: + result = await self._create_entry( + import_data.get(CONF_API_KEY, ""), + on_failure=self._import_issue, + ) + except AbortFlow: + self._deprecated_yaml_issue() + raise + + if result["type"] == FlowResultType.CREATE_ENTRY: + self._deprecated_yaml_issue() + return result diff --git a/homeassistant/components/hydrawise/const.py b/homeassistant/components/hydrawise/const.py index 515fdaec2b1..dc53d847b1f 100644 --- a/homeassistant/components/hydrawise/const.py +++ b/homeassistant/components/hydrawise/const.py @@ -8,12 +8,11 @@ LOGGER = logging.getLogger(__package__) ALLOWED_WATERING_TIME = [5, 10, 15, 30, 45, 60] CONF_WATERING_TIME = "watering_minutes" -NOTIFICATION_ID = "hydrawise_notification" -NOTIFICATION_TITLE = "Hydrawise Setup" - DOMAIN = "hydrawise" DEFAULT_WATERING_TIME = 15 +MANUFACTURER = "Hydrawise" + SCAN_INTERVAL = timedelta(seconds=120) SIGNAL_UPDATE_HYDRAWISE = "hydrawise_update" diff --git a/homeassistant/components/hydrawise/entity.py b/homeassistant/components/hydrawise/entity.py index 98b66069913..c3f295e1c4d 100644 --- a/homeassistant/components/hydrawise/entity.py +++ b/homeassistant/components/hydrawise/entity.py @@ -3,9 +3,11 @@ from __future__ import annotations from typing import Any +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 HydrawiseDataUpdateCoordinator @@ -13,6 +15,7 @@ class HydrawiseEntity(CoordinatorEntity[HydrawiseDataUpdateCoordinator]): """Entity class for Hydrawise devices.""" _attr_attribution = "Data provided by hydrawise.com" + _attr_has_entity_name = True def __init__( self, @@ -20,14 +23,16 @@ class HydrawiseEntity(CoordinatorEntity[HydrawiseDataUpdateCoordinator]): data: dict[str, Any], coordinator: HydrawiseDataUpdateCoordinator, description: EntityDescription, + device_id_key: str = "relay_id", ) -> None: """Initialize the Hydrawise entity.""" super().__init__(coordinator=coordinator) self.data = data self.entity_description = description - self._attr_name = f"{self.data['name']} {description.name}" - - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return the state attributes.""" - return {"identifier": self.data.get("relay")} + self._device_id = str(data.get(device_id_key)) + self._attr_unique_id = f"{self._device_id}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._device_id)}, + name=data["name"], + manufacturer=MANUFACTURER, + ) diff --git a/homeassistant/components/hydrawise/manifest.json b/homeassistant/components/hydrawise/manifest.json index f9de9bf30c9..eea4a0e2ebf 100644 --- a/homeassistant/components/hydrawise/manifest.json +++ b/homeassistant/components/hydrawise/manifest.json @@ -2,6 +2,7 @@ "domain": "hydrawise", "name": "Hunter Hydrawise", "codeowners": ["@dknowles2", "@ptcryan"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/hydrawise", "iot_class": "cloud_polling", "loggers": ["pydrawise"], diff --git a/homeassistant/components/hydrawise/sensor.py b/homeassistant/components/hydrawise/sensor.py index fa82c058f5b..a5bd9251a33 100644 --- a/homeassistant/components/hydrawise/sensor.py +++ b/homeassistant/components/hydrawise/sensor.py @@ -1,7 +1,6 @@ """Support for Hydrawise sprinkler sensors.""" from __future__ import annotations -from pydrawise.legacy import LegacyHydrawise import voluptuous as vol from homeassistant.components.sensor import ( @@ -10,6 +9,7 @@ from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_MONITORED_CONDITIONS, UnitOfTime from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv @@ -24,12 +24,12 @@ from .entity import HydrawiseEntity SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="next_cycle", - name="Next Cycle", + translation_key="next_cycle", device_class=SensorDeviceClass.TIMESTAMP, ), SensorEntityDescription( key="watering_time", - name="Watering Time", + translation_key="watering_time", icon="mdi:water-pump", native_unit_of_measurement=UnitOfTime.MINUTES, ), @@ -37,6 +37,8 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] +# Deprecated since Home Assistant 2023.10.0 +# Can be removed completely in 2024.4.0 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Optional(CONF_MONITORED_CONDITIONS, default=SENSOR_KEYS): vol.All( @@ -56,18 +58,25 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up a sensor for a Hydrawise device.""" - coordinator: HydrawiseDataUpdateCoordinator = hass.data[DOMAIN] - hydrawise: LegacyHydrawise = coordinator.api - monitored_conditions = config[CONF_MONITORED_CONDITIONS] + # We don't need to trigger import flow from here as it's triggered from `__init__.py` + return + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Hydrawise sensor platform.""" + coordinator: HydrawiseDataUpdateCoordinator = hass.data[DOMAIN][ + config_entry.entry_id + ] entities = [ HydrawiseSensor(data=zone, coordinator=coordinator, description=description) - for zone in hydrawise.relays + for zone in coordinator.api.relays for description in SENSOR_TYPES - if description.key in monitored_conditions ] - - add_entities(entities, True) + async_add_entities(entities) class HydrawiseSensor(HydrawiseEntity, SensorEntity): diff --git a/homeassistant/components/hydrawise/strings.json b/homeassistant/components/hydrawise/strings.json new file mode 100644 index 00000000000..8f079abcc7d --- /dev/null +++ b/homeassistant/components/hydrawise/strings.json @@ -0,0 +1,48 @@ +{ + "config": { + "step": { + "user": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + } + }, + "issues": { + "deprecated_yaml_import_issue": { + "title": "The Hydrawise YAML configuration import failed", + "description": "Configuring Hydrawise using YAML is being removed but there was an {error_type} error importing your YAML configuration.\n\nEnsure connection to Hydrawise works and restart Home Assistant to try again or remove the Hydrawise YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." + } + }, + "entity": { + "binary_sensor": { + "watering": { + "name": "Watering" + } + }, + "sensor": { + "next_cycle": { + "name": "Next cycle" + }, + "watering_time": { + "name": "Watering time" + } + }, + "switch": { + "auto_watering": { + "name": "Automatic watering" + }, + "manual_watering": { + "name": "Manual watering" + } + } + } +} diff --git a/homeassistant/components/hydrawise/switch.py b/homeassistant/components/hydrawise/switch.py index 0dd694a47d6..8cdb5b67561 100644 --- a/homeassistant/components/hydrawise/switch.py +++ b/homeassistant/components/hydrawise/switch.py @@ -3,7 +3,6 @@ from __future__ import annotations from typing import Any -from pydrawise.legacy import LegacyHydrawise import voluptuous as vol from homeassistant.components.switch import ( @@ -12,6 +11,7 @@ from homeassistant.components.switch import ( SwitchEntity, SwitchEntityDescription, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_MONITORED_CONDITIONS from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv @@ -31,18 +31,20 @@ from .entity import HydrawiseEntity SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = ( SwitchEntityDescription( key="auto_watering", - name="Automatic Watering", + translation_key="auto_watering", device_class=SwitchDeviceClass.SWITCH, ), SwitchEntityDescription( key="manual_watering", - name="Manual Watering", + translation_key="manual_watering", device_class=SwitchDeviceClass.SWITCH, ), ) SWITCH_KEYS: list[str] = [desc.key for desc in SWITCH_TYPES] +# Deprecated since Home Assistant 2023.10.0 +# Can be removed completely in 2024.4.0 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Optional(CONF_MONITORED_CONDITIONS, default=SWITCH_KEYS): vol.All( @@ -62,10 +64,20 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up a sensor for a Hydrawise device.""" - coordinator: HydrawiseDataUpdateCoordinator = hass.data[DOMAIN] - hydrawise: LegacyHydrawise = coordinator.api - monitored_conditions: list[str] = config[CONF_MONITORED_CONDITIONS] - default_watering_timer: int = config[CONF_WATERING_TIME] + # We don't need to trigger import flow from here as it's triggered from `__init__.py` + return + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Hydrawise switch platform.""" + coordinator: HydrawiseDataUpdateCoordinator = hass.data[DOMAIN][ + config_entry.entry_id + ] + default_watering_timer = DEFAULT_WATERING_TIME entities = [ HydrawiseSwitch( @@ -74,12 +86,11 @@ def setup_platform( description=description, default_watering_timer=default_watering_timer, ) - for zone in hydrawise.relays + for zone in coordinator.api.relays for description in SWITCH_TYPES - if description.key in monitored_conditions ] - add_entities(entities, True) + async_add_entities(entities) class HydrawiseSwitch(HydrawiseEntity, SwitchEntity): diff --git a/homeassistant/components/hyperion/camera.py b/homeassistant/components/hyperion/camera.py index 9c9e509947d..23ce2715140 100644 --- a/homeassistant/components/hyperion/camera.py +++ b/homeassistant/components/hyperion/camera.py @@ -119,7 +119,7 @@ class HyperionCamera(Camera): """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 @@ class HyperionCamera(Camera): 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 @@ class HyperionCamera(Camera): 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 @@ class HyperionCamera(Camera): """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 105e577efad..824d83591ef 100644 --- a/homeassistant/components/hyperion/light.py +++ b/homeassistant/components/hyperion/light.py @@ -132,7 +132,7 @@ class HyperionLight(LightEntity): 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 @@ class HyperionLight(LightEntity): 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 @@ class HyperionLight(LightEntity): """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 11e1dc199be..eb7b260a370 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 @@ class HyperionComponentSwitch(SwitchEntity): 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 @@ class HyperionComponentSwitch(SwitchEntity): 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 @@ class HyperionComponentSwitch(SwitchEntity): """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 @@ class HyperionComponentSwitch(SwitchEntity): 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 9554d30df45..fceb0d72213 100644 --- a/homeassistant/components/iaqualink/__init__.py +++ b/homeassistant/components/iaqualink/__init__.py @@ -6,7 +6,7 @@ from collections.abc import Awaitable, Callable, Coroutine 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 @@ class AqualinkEntity(Entity): 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 @@ class AqualinkEntity(Entity): 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 7513a15272c..149261f97fc 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 7c67dbdea4b..b7dbe43fca9 100644 --- a/homeassistant/components/iaqualink/climate.py +++ b/homeassistant/components/iaqualink/climate.py @@ -4,6 +4,8 @@ from __future__ import annotations 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 @@ class HassAqualinkThermostat(AqualinkEntity, ClimateEntity): 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 8b83f701915..3a166ba593d 100644 --- a/homeassistant/components/iaqualink/light.py +++ b/homeassistant/components/iaqualink/light.py @@ -3,6 +3,8 @@ from __future__ import annotations 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 @@ class HassAqualinkLight(AqualinkEntity, LightEntity): 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 8086aa29ee0..15e8fc5836d 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 @@ class HassAqualinkSensor(AqualinkEntity, SensorEntity): 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 8f482e8730f..590fcd61419 100644 --- a/homeassistant/components/iaqualink/switch.py +++ b/homeassistant/components/iaqualink/switch.py @@ -3,6 +3,8 @@ from __future__ import annotations 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/icloud/device_tracker.py b/homeassistant/components/icloud/device_tracker.py index 0bd1dfb44a9..8513b47be2a 100644 --- a/homeassistant/components/icloud/device_tracker.py +++ b/homeassistant/components/icloud/device_tracker.py @@ -64,11 +64,7 @@ class IcloudTrackerEntity(TrackerEntity): self._account = account self._device = device self._unsub_dispatcher: CALLBACK_TYPE | None = None - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return self._device.unique_id + self._attr_unique_id = device.unique_id @property def location_accuracy(self): diff --git a/homeassistant/components/icloud/sensor.py b/homeassistant/components/icloud/sensor.py index e92a9ae4a8d..320c3f9f240 100644 --- a/homeassistant/components/icloud/sensor.py +++ b/homeassistant/components/icloud/sensor.py @@ -63,11 +63,14 @@ class IcloudDeviceBatterySensor(SensorEntity): self._account = account self._device = device self._unsub_dispatcher: CALLBACK_TYPE | None = None - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return f"{self._device.unique_id}_battery" + self._attr_unique_id = f"{device.unique_id}_battery" + self._attr_device_info = DeviceInfo( + configuration_url="https://icloud.com/", + identifiers={(DOMAIN, device.unique_id)}, + manufacturer="Apple", + model=device.device_model, + name=device.name, + ) @property def native_value(self) -> int | None: @@ -87,17 +90,6 @@ class IcloudDeviceBatterySensor(SensorEntity): """Return default attributes for the iCloud device entity.""" return self._device.extra_state_attributes - @property - def device_info(self) -> DeviceInfo: - """Return the device information.""" - return DeviceInfo( - configuration_url="https://icloud.com/", - identifiers={(DOMAIN, self._device.unique_id)}, - manufacturer="Apple", - model=self._device.device_model, - name=self._device.name, - ) - async def async_added_to_hass(self) -> None: """Register state update callback.""" self._unsub_dispatcher = async_dispatcher_connect( diff --git a/homeassistant/components/idasen_desk/__init__.py b/homeassistant/components/idasen_desk/__init__.py new file mode 100644 index 00000000000..5fd23ba47e0 --- /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 00000000000..f56446396d2 --- /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 00000000000..0d37d77307b --- /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 00000000000..c1d1bb48fd8 --- /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 00000000000..f77e0c22373 --- /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 00000000000..f7459906ac8 --- /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": "No unconfigured devices found. Make sure that the desk is in Bluetooth pairing mode. Enter pairing mode by pressing the small button with the Bluetooth logo on the controller for about 3 seconds, until it starts blinking." + } + } +} diff --git a/homeassistant/components/image/__init__.py b/homeassistant/components/image/__init__.py index d1895053f02..e5c40affe0f 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 5c141220881..00000000000 --- 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/image_upload/manifest.json b/homeassistant/components/image_upload/manifest.json index 4f139785cd3..b6c74f0c53c 100644 --- a/homeassistant/components/image_upload/manifest.json +++ b/homeassistant/components/image_upload/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/image_upload", "integration_type": "system", "quality_scale": "internal", - "requirements": ["Pillow==10.0.0"] + "requirements": ["Pillow==10.0.1"] } diff --git a/homeassistant/components/imap/coordinator.py b/homeassistant/components/imap/coordinator.py index 72be5e9bcf0..59c24b11e51 100644 --- a/homeassistant/components/imap/coordinator.py +++ b/homeassistant/components/imap/coordinator.py @@ -110,6 +110,15 @@ class ImapMessage: 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 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]): """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 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | 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 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | 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 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | 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 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[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 f19b0499040..8fe05f80c08 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 @@ class DeprecationRepairFlow(RepairsFlow): 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 a074b3b9b65..613e8829aa1 100644 --- a/homeassistant/components/input_boolean/__init__.py +++ b/homeassistant/components/input_boolean/__init__.py @@ -22,9 +22,6 @@ from homeassistant.helpers import collection 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 async_setup(hass: HomeAssistant, config: ConfigType) -> bool: 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 8e94dc93f3b..00000000000 --- 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 c04b18b0c25..3318354392c 100644 --- a/homeassistant/components/input_button/__init__.py +++ b/homeassistant/components/input_button/__init__.py @@ -18,9 +18,6 @@ from homeassistant.core import HomeAssistant, ServiceCall, callback 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 async_setup(hass: HomeAssistant, config: ConfigType) -> bool: 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 8e94dc93f3b..00000000000 --- 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_button/services.yaml b/homeassistant/components/input_button/services.yaml index 7c57fcff272..8e737ac7055 100644 --- a/homeassistant/components/input_button/services.yaml +++ b/homeassistant/components/input_button/services.yaml @@ -2,3 +2,5 @@ press: target: entity: domain: input_button + +reload: diff --git a/homeassistant/components/input_button/strings.json b/homeassistant/components/input_button/strings.json index b51d04926f5..d36871917a9 100644 --- a/homeassistant/components/input_button/strings.json +++ b/homeassistant/components/input_button/strings.json @@ -18,6 +18,10 @@ "press": { "name": "Press", "description": "Mimics the physical button press on the device." + }, + "reload": { + "name": "[%key:common::action::reload%]", + "description": "Reloads helpers from the YAML-configuration." } } } diff --git a/homeassistant/components/input_datetime/__init__.py b/homeassistant/components/input_datetime/__init__.py index 81882137fad..73a4df12d03 100644 --- a/homeassistant/components/input_datetime/__init__.py +++ b/homeassistant/components/input_datetime/__init__.py @@ -20,9 +20,6 @@ from homeassistant.core import HomeAssistant, ServiceCall, callback 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 @@ class DateTimeStorageCollection(collection.DictStorageCollection): 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 91c33ee0811..00000000000 --- 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 197a35246d2..4a74201be15 100644 --- a/homeassistant/components/input_number/__init__.py +++ b/homeassistant/components/input_number/__init__.py @@ -21,9 +21,6 @@ from homeassistant.core import HomeAssistant, ServiceCall, callback 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 @@ class NumberStorageCollection(collection.DictStorageCollection): 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 05a5023be0b..00000000000 --- 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 e1354cb26a5..4a384e0c17a 100644 --- a/homeassistant/components/input_select/__init__.py +++ b/homeassistant/components/input_select/__init__.py @@ -29,9 +29,6 @@ from homeassistant.exceptions import HomeAssistantError 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 @@ class InputSelectStorageCollection(collection.DictStorageCollection): 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 8e94dc93f3b..00000000000 --- 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 096e7cbb105..81b75458dc1 100644 --- a/homeassistant/components/input_text/__init__.py +++ b/homeassistant/components/input_text/__init__.py @@ -20,9 +20,6 @@ from homeassistant.core import HomeAssistant, ServiceCall, callback 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 @@ class InputTextStorageCollection(collection.DictStorageCollection): 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 0f4969270d0..00000000000 --- 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 7350ab14743..80a76e482e5 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/binary_sensor.py b/homeassistant/components/insteon/binary_sensor.py index f895b9c7f6a..02af89dba01 100644 --- a/homeassistant/components/insteon/binary_sensor.py +++ b/homeassistant/components/insteon/binary_sensor.py @@ -76,12 +76,7 @@ class InsteonBinarySensorEntity(InsteonEntity, BinarySensorEntity): def __init__(self, device, group): """Initialize the INSTEON binary sensor.""" super().__init__(device, group) - self._sensor_type = SENSOR_TYPES.get(self._insteon_device_group.name) - - @property - def device_class(self): - """Return the class of this sensor.""" - return self._sensor_type + self._attr_device_class = SENSOR_TYPES.get(self._insteon_device_group.name) @property def is_on(self): diff --git a/homeassistant/components/insteon/climate.py b/homeassistant/components/insteon/climate.py index 48ff898d6aa..74fb11491c0 100644 --- a/homeassistant/components/insteon/climate.py +++ b/homeassistant/components/insteon/climate.py @@ -88,6 +88,9 @@ class InsteonClimateEntity(InsteonEntity, ClimateEntity): | ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE ) + _attr_hvac_modes = list(HVAC_MODES.values()) + _attr_fan_modes = list(FAN_MODES.values()) + _attr_min_humidity = 1 @property def temperature_unit(self) -> str: @@ -106,11 +109,6 @@ class InsteonClimateEntity(InsteonEntity, ClimateEntity): """Return hvac operation ie. heat, cool mode.""" return HVAC_MODES[self._insteon_device.groups[SYSTEM_MODE].value] - @property - def hvac_modes(self) -> list[HVACMode]: - """Return the list of available hvac operation modes.""" - return list(HVAC_MODES.values()) - @property def current_temperature(self) -> float | None: """Return the current temperature.""" @@ -144,11 +142,6 @@ class InsteonClimateEntity(InsteonEntity, ClimateEntity): """Return the fan setting.""" return FAN_MODES[self._insteon_device.groups[FAN_MODE].value] - @property - def fan_modes(self) -> list[str] | None: - """Return the list of available fan modes.""" - return list(FAN_MODES.values()) - @property def target_humidity(self) -> int | None: """Return the humidity we try to reach.""" @@ -157,11 +150,6 @@ class InsteonClimateEntity(InsteonEntity, ClimateEntity): # May not be loaded yet so return a default if required return (high + low) / 2 if high and low else None - @property - def min_humidity(self) -> int: - """Return the minimum humidity.""" - return 1 - @property def hvac_action(self) -> HVACAction: """Return the current running hvac operation if supported. diff --git a/homeassistant/components/insteon/fan.py b/homeassistant/components/insteon/fan.py index 92f56098a91..da9e3de6422 100644 --- a/homeassistant/components/insteon/fan.py +++ b/homeassistant/components/insteon/fan.py @@ -50,6 +50,7 @@ class InsteonFanEntity(InsteonEntity, FanEntity): """An INSTEON fan entity.""" _attr_supported_features = FanEntityFeature.SET_SPEED + _attr_speed_count = 3 @property def percentage(self) -> int | None: @@ -58,11 +59,6 @@ class InsteonFanEntity(InsteonEntity, FanEntity): return None return ranged_value_to_percentage(SPEED_RANGE, self._insteon_device_group.value) - @property - def speed_count(self) -> int: - """Flag supported features.""" - return 3 - async def async_turn_on( self, percentage: int | None = None, diff --git a/homeassistant/components/insteon/ipdb.py b/homeassistant/components/insteon/ipdb.py index de3ba7d55f2..9e9f987d611 100644 --- a/homeassistant/components/insteon/ipdb.py +++ b/homeassistant/components/insteon/ipdb.py @@ -10,6 +10,7 @@ from pyinsteon.device_types.ipdb import ( DimmableLightingControl_Dial, DimmableLightingControl_DinRail, DimmableLightingControl_FanLinc, + DimmableLightingControl_I3_KeypadLinc_4, DimmableLightingControl_InLineLinc01, DimmableLightingControl_InLineLinc02, DimmableLightingControl_KeypadLinc_6, @@ -55,6 +56,9 @@ DEVICE_PLATFORM: dict[Device, dict[Platform, Iterable[int]]] = { 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 1c12bc794f9..121d8d62c66 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 ad3fb7bfbe8..5fa45a16fb6 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/intent_script/__init__.py b/homeassistant/components/intent_script/__init__.py index 55c4947fe4a..f0cf36b5607 100644 --- a/homeassistant/components/intent_script/__init__.py +++ b/homeassistant/components/intent_script/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +from typing import TypedDict import voluptuous as vol @@ -62,7 +63,7 @@ CONFIG_SCHEMA = vol.Schema( ) -async def async_reload(hass: HomeAssistant, servie_call: ServiceCall) -> None: +async def async_reload(hass: HomeAssistant, service_call: ServiceCall) -> None: """Handle start Intent Script service call.""" new_config = await async_integration_yaml_config(hass, DOMAIN) existing_intents = hass.data[DOMAIN] @@ -79,7 +80,7 @@ async def async_reload(hass: HomeAssistant, servie_call: ServiceCall) -> None: async_load_intents(hass, new_intents) -def async_load_intents(hass: HomeAssistant, intents: dict): +def async_load_intents(hass: HomeAssistant, intents: dict[str, ConfigType]) -> None: """Load YAML intents into the intent system.""" template.attach(hass, intents) hass.data[DOMAIN] = intents @@ -98,8 +99,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async_load_intents(hass, intents) - async def _handle_reload(servie_call: ServiceCall) -> None: - return await async_reload(hass, servie_call) + async def _handle_reload(service_call: ServiceCall) -> None: + return await async_reload(hass, service_call) service.async_register_admin_service( hass, @@ -111,22 +112,41 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True +class _IntentSpeechRepromptData(TypedDict): + """Intent config data type for speech or reprompt info.""" + + content: template.Template + title: template.Template + text: template.Template + type: str + + +class _IntentCardData(TypedDict): + """Intent config data type for card info.""" + + type: str + title: template.Template + content: template.Template + + class ScriptIntentHandler(intent.IntentHandler): """Respond to an intent with a script.""" - def __init__(self, intent_type, config): + def __init__(self, intent_type: str, config: ConfigType) -> None: """Initialize the script intent handler.""" self.intent_type = intent_type self.config = config - async def async_handle(self, intent_obj: intent.Intent): + async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: """Handle the intent.""" - speech = self.config.get(CONF_SPEECH) - reprompt = self.config.get(CONF_REPROMPT) - card = self.config.get(CONF_CARD) - action = self.config.get(CONF_ACTION) - is_async_action = self.config.get(CONF_ASYNC_ACTION) - slots = {key: value["value"] for key, value in intent_obj.slots.items()} + speech: _IntentSpeechRepromptData | None = self.config.get(CONF_SPEECH) + reprompt: _IntentSpeechRepromptData | None = self.config.get(CONF_REPROMPT) + card: _IntentCardData | None = self.config.get(CONF_CARD) + action: script.Script | None = self.config.get(CONF_ACTION) + is_async_action: bool = self.config[CONF_ASYNC_ACTION] + slots: dict[str, str] = { + key: value["value"] for key, value in intent_obj.slots.items() + } _LOGGER.debug( "Intent named %s received with slots: %s", @@ -150,23 +170,23 @@ class ScriptIntentHandler(intent.IntentHandler): if speech is not None: response.async_set_speech( - speech[CONF_TEXT].async_render(slots, parse_result=False), - speech[CONF_TYPE], + speech["text"].async_render(slots, parse_result=False), + speech["type"], ) if reprompt is not None: - text_reprompt = reprompt[CONF_TEXT].async_render(slots, parse_result=False) + text_reprompt = reprompt["text"].async_render(slots, parse_result=False) if text_reprompt: response.async_set_reprompt( text_reprompt, - reprompt[CONF_TYPE], + reprompt["type"], ) if card is not None: response.async_set_card( - card[CONF_TITLE].async_render(slots, parse_result=False), - card[CONF_CONTENT].async_render(slots, parse_result=False), - card[CONF_TYPE], + card["title"].async_render(slots, parse_result=False), + card["content"].async_render(slots, parse_result=False), + card["type"], ) return response diff --git a/homeassistant/components/ios/sensor.py b/homeassistant/components/ios/sensor.py index 45cd3586af2..610cea8c814 100644 --- a/homeassistant/components/ios/sensor.py +++ b/homeassistant/components/ios/sensor.py @@ -137,7 +137,7 @@ class IOSSensor(SensorEntity): 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/manifest.json b/homeassistant/components/ipma/manifest.json index 4fea047e834..0d7df3fcf92 100644 --- a/homeassistant/components/ipma/manifest.json +++ b/homeassistant/components/ipma/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/ipma", "iot_class": "cloud_polling", "loggers": ["geopy", "pyipma"], - "requirements": ["pyipma==3.0.6"] + "requirements": ["pyipma==3.0.7"] } diff --git a/homeassistant/components/ipma/sensor.py b/homeassistant/components/ipma/sensor.py index 7f5782f3f89..cb0620ceca0 100644 --- a/homeassistant/components/ipma/sensor.py +++ b/homeassistant/components/ipma/sensor.py @@ -8,6 +8,8 @@ import logging from pyipma.api import IPMA_API from pyipma.location import Location +from pyipma.rcm import RCM +from pyipma.uv import UV from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.config_entries import ConfigEntry @@ -33,19 +35,32 @@ class IPMASensorEntityDescription(SensorEntityDescription, IPMARequiredKeysMixin """Describes IPMA sensor entity.""" -async def async_retrive_rcm(location: Location, api: IPMA_API) -> int | None: +async def async_retrieve_rcm(location: Location, api: IPMA_API) -> int | None: """Retrieve RCM.""" - fire_risk = await location.fire_risk(api) + fire_risk: RCM = await location.fire_risk(api) if fire_risk: return fire_risk.rcm return None +async def async_retrieve_uvi(location: Location, api: IPMA_API) -> int | None: + """Retrieve UV.""" + uv_risk: UV = await location.uv_risk(api) + if uv_risk: + return round(uv_risk.iUv) + return None + + SENSOR_TYPES: tuple[IPMASensorEntityDescription, ...] = ( IPMASensorEntityDescription( key="rcm", translation_key="fire_risk", - value_fn=async_retrive_rcm, + value_fn=async_retrieve_rcm, + ), + IPMASensorEntityDescription( + key="uvi", + translation_key="uv_index", + value_fn=async_retrieve_uvi, ), ) @@ -81,7 +96,7 @@ class IPMASensor(SensorEntity, IPMADevice): @Throttle(MIN_TIME_BETWEEN_UPDATES) async def async_update(self) -> None: - """Update Fire risk.""" + """Update sensors.""" async with asyncio.timeout(10): self._attr_native_value = await self.entity_description.value_fn( self._location, self._api diff --git a/homeassistant/components/ipma/strings.json b/homeassistant/components/ipma/strings.json index b9b672e77d9..ea5e5ff4759 100644 --- a/homeassistant/components/ipma/strings.json +++ b/homeassistant/components/ipma/strings.json @@ -28,6 +28,9 @@ "sensor": { "fire_risk": { "name": "Fire risk" + }, + "uv_index": { + "name": "UV index" } } } diff --git a/homeassistant/components/iqvia/manifest.json b/homeassistant/components/iqvia/manifest.json index 315d063d6aa..ce519de1b67 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 d0d314fe67d..333b6b36c87 100644 --- a/homeassistant/components/islamic_prayer_times/config_flow.py +++ b/homeassistant/components/islamic_prayer_times/config_flow.py @@ -8,8 +8,28 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.selector import ( + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) -from .const import CALC_METHODS, CONF_CALC_METHOD, DEFAULT_CALC_METHOD, DOMAIN, NAME +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 @@ class IslamicPrayerOptionsFlowHandler(config_entries.OptionsFlow): 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 2a73a33bef8..926651738a2 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 1a8b0bf7036..161ce7b2644 100644 --- a/homeassistant/components/islamic_prayer_times/coordinator.py +++ b/homeassistant/components/islamic_prayer_times/coordinator.py @@ -3,6 +3,7 @@ from __future__ import annotations 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.event import async_call_later, async_track_point_in_t 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 @@ class IslamicPrayerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, datetim """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 @@ class IslamicPrayerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, datetim 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 7c09cc605bd..e07a38ca107 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 69db4afd1be..7be3b87a0d3 100644 --- a/homeassistant/components/isy994/binary_sensor.py +++ b/homeassistant/components/isy994/binary_sensor.py @@ -23,9 +23,8 @@ from homeassistant.const import STATE_ON, Platform 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 @@ class ISYBinarySensorEntity(ISYNodeEntity, BinarySensorEntity): ) -> 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 @@ class ISYBinarySensorEntity(ISYNodeEntity, BinarySensorEntity): 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. @@ -421,6 +413,8 @@ class ISYInsteonBinarySensorEntity(ISYBinarySensorEntity): class ISYBinarySensorHeartbeat(ISYNodeEntity, BinarySensorEntity, RestoreEntity): """Representation of the battery state of an ISY sensor.""" + _attr_device_class = BinarySensorDeviceClass.BATTERY + def __init__( self, node: Node, @@ -494,15 +488,8 @@ class ISYBinarySensorHeartbeat(ISYNodeEntity, BinarySensorEntity, RestoreEntity) 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 @@ -522,11 +509,6 @@ class ISYBinarySensorHeartbeat(ISYNodeEntity, BinarySensorEntity, RestoreEntity) """ return bool(self._computed_state) - @property - def device_class(self) -> BinarySensorDeviceClass: - """Get the class of this device.""" - return BinarySensorDeviceClass.BATTERY - @property def extra_state_attributes(self) -> dict[str, Any]: """Get the state attributes for the device.""" diff --git a/homeassistant/components/isy994/climate.py b/homeassistant/components/isy994/climate.py index 4ddbbd86060..3ac2fd18473 100644 --- a/homeassistant/components/isy994/climate.py +++ b/homeassistant/components/isy994/climate.py @@ -83,6 +83,8 @@ class ISYThermostatEntity(ISYNodeEntity, ClimateEntity): | ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE ) + _attr_target_temperature_step = 1.0 + _attr_fan_modes = [FAN_AUTO, FAN_ON] def __init__(self, node: Node, device_info: DeviceInfo | None = None) -> None: """Initialize the ISY Thermostat entity.""" @@ -90,13 +92,6 @@ class ISYThermostatEntity(ISYNodeEntity, ClimateEntity): self._uom = self._node.uom if isinstance(self._uom, list): self._uom = self._node.uom[0] - self._hvac_action: str | None = None - self._hvac_mode: str | None = None - self._fan_mode: str | None = None - self._temp_unit = None - self._current_humidity = 0 - self._target_temp_low = 0 - self._target_temp_high = 0 @property def temperature_unit(self) -> str: @@ -155,11 +150,6 @@ class ISYThermostatEntity(ISYNodeEntity, ClimateEntity): self._node.status, self._uom, self._node.prec, 1 ) - @property - def target_temperature_step(self) -> float | None: - """Return the supported step of target temperature.""" - return 1.0 - @property def target_temperature(self) -> float | None: """Return the temperature we try to reach.""" @@ -185,11 +175,6 @@ class ISYThermostatEntity(ISYNodeEntity, ClimateEntity): return None return convert_isy_value_to_hass(target.value, target.uom, target.prec, 1) - @property - def fan_modes(self) -> list[str]: - """Return the list of available fan modes.""" - return [FAN_AUTO, FAN_ON] - @property def fan_mode(self) -> str: """Return the current fan mode ie. auto, on.""" @@ -210,26 +195,18 @@ class ISYThermostatEntity(ISYNodeEntity, ClimateEntity): target_temp_low = target_temp if target_temp_low is not None: await self._node.set_climate_setpoint_heat(int(target_temp_low)) - # Presumptive setting--event stream will correct if cmd fails: - self._target_temp_low = target_temp_low if target_temp_high is not None: await self._node.set_climate_setpoint_cool(int(target_temp_high)) - # Presumptive setting--event stream will correct if cmd fails: - self._target_temp_high = target_temp_high self.async_write_ha_state() async def async_set_fan_mode(self, fan_mode: str) -> None: """Set new target fan mode.""" _LOGGER.debug("Requested fan mode %s", fan_mode) await self._node.set_fan_mode(HA_FAN_TO_ISY.get(fan_mode)) - # Presumptive setting--event stream will correct if cmd fails: - self._fan_mode = fan_mode self.async_write_ha_state() async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" _LOGGER.debug("Requested operation mode %s", hvac_mode) await self._node.set_climate_mode(HA_HVAC_TO_ISY.get(hvac_mode)) - # Presumptive setting--event stream will correct if cmd fails: - self._hvac_mode = hvac_mode self.async_write_ha_state() diff --git a/homeassistant/components/isy994/entity.py b/homeassistant/components/isy994/entity.py index 80319b83ba2..a93f2d91d31 100644 --- a/homeassistant/components/isy994/entity.py +++ b/homeassistant/components/isy994/entity.py @@ -112,19 +112,19 @@ class ISYNodeEntity(ISYEntity): other attributes which have been picked up from the event stream and the combined result are returned as the device state attributes. """ - attr = {} + attrs = self._attrs node = self._node # Insteon aux_properties are now their own sensors - if hasattr(self._node, "aux_properties") and node.protocol != PROTO_INSTEON: + # so we no longer need to add them to the attributes + if node.protocol != PROTO_INSTEON and hasattr(node, "aux_properties"): for name, value in self._node.aux_properties.items(): attr_name = COMMAND_FRIENDLY_NAME.get(name, name) - attr[attr_name] = str(value.formatted).lower() + attrs[attr_name] = str(value.formatted).lower() # If a Group/Scene, set a property if the entire scene is on/off - if hasattr(self._node, "group_all_on"): - attr["group_all_on"] = STATE_ON if self._node.group_all_on else STATE_OFF + if hasattr(node, "group_all_on"): + attrs["group_all_on"] = STATE_ON if node.group_all_on else STATE_OFF - self._attrs.update(attr) return self._attrs async def async_send_node_command(self, command: str) -> None: diff --git a/homeassistant/components/isy994/sensor.py b/homeassistant/components/isy994/sensor.py index b1899100dd4..1a160024a65 100644 --- a/homeassistant/components/isy994/sensor.py +++ b/homeassistant/components/isy994/sensor.py @@ -262,6 +262,7 @@ class ISYAuxSensorEntity(ISYSensorEntity): """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 39b84faad30..de64741ba3a 100644 --- a/homeassistant/components/isy994/switch.py +++ b/homeassistant/components/isy994/switch.py @@ -112,6 +112,8 @@ class ISYSwitchEntity(ISYNodeEntity, SwitchEntity): class ISYSwitchProgramEntity(ISYProgramEntity, SwitchEntity): """A representation of an ISY program switch.""" + _attr_icon = "mdi:script-text-outline" # Matches isy program icon + @property def is_on(self) -> bool: """Get whether the ISY switch program is on.""" @@ -131,11 +133,6 @@ class ISYSwitchProgramEntity(ISYProgramEntity, SwitchEntity): f"Unable to run 'else' clause on program switch {self._actions.address}" ) - @property - def icon(self) -> str: - """Get the icon for programs.""" - return "mdi:script-text-outline" # Matches isy program icon - class ISYEnableSwitchEntity(ISYAuxControlEntity, SwitchEntity): """A representation of an ISY enable/disable switch.""" @@ -159,6 +156,7 @@ class ISYEnableSwitchEntity(ISYAuxControlEntity, SwitchEntity): 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/izone/climate.py b/homeassistant/components/izone/climate.py index 2dcdd72f6b9..1ff016c3177 100644 --- a/homeassistant/components/izone/climate.py +++ b/homeassistant/components/izone/climate.py @@ -135,6 +135,7 @@ class ControllerDevice(ClimateEntity): _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_has_entity_name = True _attr_name = None + _attr_target_temperature_step = 0.5 def __init__(self, controller: Controller) -> None: """Initialise ControllerDevice.""" @@ -165,13 +166,13 @@ class ControllerDevice(ClimateEntity): self._fan_to_pizone = {} for fan in controller.fan_modes: self._fan_to_pizone[_IZONE_FAN_TO_HA[fan]] = fan - self._available = True + self._attr_unique_id = controller.device_uid self._attr_device_info = DeviceInfo( - identifiers={(IZONE, self.unique_id)}, + identifiers={(IZONE, controller.device_uid)}, manufacturer="IZone", - model=self._controller.sys_type, - name=f"iZone Controller {self._controller.device_uid}", + model=controller.sys_type, + name=f"iZone Controller {controller.device_uid}", ) # Create the zones @@ -224,11 +225,6 @@ class ControllerDevice(ClimateEntity): ) ) - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self._available - @callback def set_available(self, available: bool, ex: Exception | None = None) -> None: """Set availability for the controller. @@ -247,17 +243,12 @@ class ControllerDevice(ClimateEntity): ex, ) - self._available = available + self._attr_available = available self.async_write_ha_state() for zone in self.zones.values(): if zone.hass is not None: zone.async_schedule_update_ha_state() - @property - def unique_id(self) -> str: - """Return the ID of the controller device.""" - return self._controller.device_uid - @property def extra_state_attributes(self) -> Mapping[str, Any]: """Return the optional state attributes.""" @@ -364,11 +355,6 @@ class ControllerDevice(ClimateEntity): """Return the current supply, or in duct, temperature.""" return self._controller.temp_supply - @property - def target_temperature_step(self) -> float | None: - """Return the supported step of target temperature.""" - return 0.5 - @property def fan_mode(self) -> str | None: """Return the fan setting.""" @@ -444,6 +430,7 @@ class ZoneDevice(ClimateEntity): _attr_has_entity_name = True _attr_name = None _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_target_temperature_step = 0.5 def __init__(self, controller: ControllerDevice, zone: Zone) -> None: """Initialise ZoneDevice.""" @@ -462,7 +449,8 @@ class ZoneDevice(ClimateEntity): HVACMode.HEAT_COOL: Zone.Mode.AUTO, } self._attr_supported_features |= ClimateEntityFeature.TARGET_TEMPERATURE - + self._attr_unique_id = f"{controller.unique_id}_z{zone.index + 1}" + assert controller.unique_id self._attr_device_info = DeviceInfo( identifiers={ (IZONE, controller.unique_id, zone.index) # type:ignore[arg-type] @@ -509,11 +497,6 @@ class ZoneDevice(ClimateEntity): """Return True if entity is available.""" return self._controller.available - @property - def unique_id(self) -> str: - """Return the ID of the controller device.""" - return f"{self._controller.unique_id}_z{self._zone.index + 1}" - @property @_return_on_connection_error(0) def supported_features(self) -> ClimateEntityFeature: @@ -548,11 +531,6 @@ class ZoneDevice(ClimateEntity): return None return self._zone.temp_setpoint - @property - def target_temperature_step(self) -> float: - """Return the supported step of target temperature.""" - return 0.5 - @property def min_temp(self) -> float: """Return the minimum temperature.""" diff --git a/homeassistant/components/juicenet/entity.py b/homeassistant/components/juicenet/entity.py index 3c325715c82..b3433948582 100644 --- a/homeassistant/components/juicenet/entity.py +++ b/homeassistant/components/juicenet/entity.py @@ -23,20 +23,12 @@ class JuiceNetDevice(CoordinatorEntity): 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 1f85c20fc72..6fdc5b4d12f 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 f39c92519e4..ab0b3370197 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/knx/manifest.json b/homeassistant/components/knx/manifest.json index a915d886138..b5c98c7203a 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -12,7 +12,7 @@ "quality_scale": "platinum", "requirements": [ "xknx==2.11.2", - "xknxproject==3.2.0", + "xknxproject==3.3.0", "knx-frontend==2023.6.23.191712" ] } diff --git a/homeassistant/components/knx/project.py b/homeassistant/components/knx/project.py index 274ef5cb9a3..d47241b174b 100644 --- a/homeassistant/components/knx/project.py +++ b/homeassistant/components/knx/project.py @@ -115,3 +115,7 @@ class KNXProject: """Remove project file from storage.""" await self._store.async_remove() self.initial_state() + + async def get_knxproject(self) -> KNXProjectModel | None: + """Load the project file from local storage.""" + return await self._store.async_load() diff --git a/homeassistant/components/knx/websocket.py b/homeassistant/components/knx/websocket.py index ad29fd19928..e3eb5de8530 100644 --- a/homeassistant/components/knx/websocket.py +++ b/homeassistant/components/knx/websocket.py @@ -27,6 +27,7 @@ async def register_panel(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, ws_project_file_remove) websocket_api.async_register_command(hass, ws_group_monitor_info) websocket_api.async_register_command(hass, ws_subscribe_telegram) + websocket_api.async_register_command(hass, ws_get_knx_project) if DOMAIN not in hass.data.get("frontend_panels", {}): hass.http.register_static_path( @@ -67,6 +68,7 @@ def ws_info( "name": project_info["name"], "last_modified": project_info["last_modified"], "tool_version": project_info["tool_version"], + "xknxproject_version": project_info["xknxproject_version"], } connection.send_result( @@ -80,6 +82,30 @@ def ws_info( ) +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "knx/get_knx_project", + } +) +@websocket_api.async_response +async def ws_get_knx_project( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict, +) -> None: + """Handle get KNX project.""" + knx: KNXModule = hass.data[DOMAIN] + knxproject = await knx.project.get_knxproject() + connection.send_result( + msg["id"], + { + "project_loaded": knx.project.loaded, + "knxproject": knxproject, + }, + ) + + @websocket_api.require_admin @websocket_api.websocket_command( { diff --git a/homeassistant/components/kodi/media_player.py b/homeassistant/components/kodi/media_player.py index 9c69abc08c8..32ecbbed626 100644 --- a/homeassistant/components/kodi/media_player.py +++ b/homeassistant/components/kodi/media_player.py @@ -282,7 +282,7 @@ class KodiEntity(MediaPlayerEntity): """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 @@ class KodiEntity(MediaPlayerEntity): 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 f7ee375f990..51431b317d6 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 2f21f8c15bd..d7c41337342 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 @@ class KonnectedBinarySensor(BinarySensorEntity): @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 b341afa765f..3f203d5f3e8 100644 --- a/homeassistant/components/konnected/sensor.py +++ b/homeassistant/components/konnected/sensor.py @@ -111,9 +111,9 @@ class KonnectedSensor(SensorEntity): 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 @@ class KonnectedSensor(SensorEntity): 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 @@ class KonnectedSensor(SensorEntity): 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 ba0dc62b606..18132a913ad 100644 --- a/homeassistant/components/konnected/switch.py +++ b/homeassistant/components/konnected/switch.py @@ -56,27 +56,13 @@ class KonnectedSwitch(SwitchEntity): 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 @@ class KonnectedSwitch(SwitchEntity): 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 @@ class KonnectedSwitch(SwitchEntity): 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 78ab609aa16..f7bad638df4 100644 --- a/homeassistant/components/kostal_plenticore/sensor.py +++ b/homeassistant/components/kostal_plenticore/sensor.py @@ -745,16 +745,16 @@ class PlenticoreDataSensor( 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 @@ class PlenticoreDataSensor( 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 574368b432f..554f8db2b68 100644 --- a/homeassistant/components/kostal_plenticore/switch.py +++ b/homeassistant/components/kostal_plenticore/switch.py @@ -116,7 +116,6 @@ class PlenticoreDataSwitch( """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 @@ class PlenticoreDataSwitch( 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 @@ class PlenticoreDataSwitch( ) 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 c68633ab639..6636bfdba9f 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 @@ class KulerskyLight(LightEntity): "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 @@ class KulerskyLight(LightEntity): 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/laundrify/binary_sensor.py b/homeassistant/components/laundrify/binary_sensor.py index 81882b68f00..5cca6870b6c 100644 --- a/homeassistant/components/laundrify/binary_sensor.py +++ b/homeassistant/components/laundrify/binary_sensor.py @@ -51,17 +51,14 @@ class LaundrifyPowerPlug( """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 caf2e15df77..15ed50ca6c5 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 13a2a5b3bb3..ceeeecf50c4 100644 --- a/homeassistant/components/lcn/binary_sensor.py +++ b/homeassistant/components/lcn/binary_sensor.py @@ -66,8 +66,6 @@ class LcnRegulatorLockSensor(LcnEntity, BinarySensorEntity): 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 @@ class LcnRegulatorLockSensor(LcnEntity, BinarySensorEntity): 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 @@ class LcnRegulatorLockSensor(LcnEntity, BinarySensorEntity): ): 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 @@ class LcnBinarySensor(LcnEntity, BinarySensorEntity): 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 @@ class LcnBinarySensor(LcnEntity, BinarySensorEntity): 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 @@ class LcnLockKeysSensor(LcnEntity, BinarySensorEntity): 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 @@ class LcnLockKeysSensor(LcnEntity, BinarySensorEntity): 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 @@ class LcnLockKeysSensor(LcnEntity, BinarySensorEntity): 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 bc83da55888..31b2dbface0 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 @@ class LcnOutputsCover(LcnEntity, CoverEntity): 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 @@ class LcnOutputsCover(LcnEntity, CoverEntity): 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 @@ class LcnOutputsCover(LcnEntity, CoverEntity): 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 @@ class LcnOutputsCover(LcnEntity, CoverEntity): 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 @@ class LcnOutputsCover(LcnEntity, CoverEntity): 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 @@ class LcnOutputsCover(LcnEntity, CoverEntity): 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 @@ class LcnOutputsCover(LcnEntity, CoverEntity): 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 @@ class LcnRelayCover(LcnEntity, CoverEntity): 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 @@ class LcnRelayCover(LcnEntity, CoverEntity): 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 @@ class LcnRelayCover(LcnEntity, CoverEntity): 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 @@ class LcnRelayCover(LcnEntity, CoverEntity): 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 38480cc3124..65c1344edf0 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 @@ class LcnOutputLight(LcnEntity, LightEntity): ) 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 @@ class LcnOutputLight(LcnEntity, LightEntity): 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 @@ class LcnOutputLight(LcnEntity, LightEntity): 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 @@ class LcnOutputLight(LcnEntity, LightEntity): ): 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 @@ class LcnOutputLight(LcnEntity, LightEntity): ): 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 @@ class LcnRelayLight(LcnEntity, LightEntity): 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 @@ class LcnRelayLight(LcnEntity, LightEntity): 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 @@ class LcnRelayLight(LcnEntity, LightEntity): 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 @@ class LcnRelayLight(LcnEntity, LightEntity): 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 66321c79a1b..1428019b59f 100644 --- a/homeassistant/components/lcn/sensor.py +++ b/homeassistant/components/lcn/sensor.py @@ -77,8 +77,7 @@ class LcnVariableSensor(LcnEntity, SensorEntity): 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 @@ class LcnVariableSensor(LcnEntity, SensorEntity): 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 @@ class LcnVariableSensor(LcnEntity, SensorEntity): ): 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 @@ class LcnLedLogicSensor(LcnEntity, SensorEntity): 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 @@ class LcnLedLogicSensor(LcnEntity, SensorEntity): 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 ded15c0f1da..8374ff85ab7 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 @@ class LcnOutputSwitch(LcnEntity, SwitchEntity): 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 @@ class LcnOutputSwitch(LcnEntity, SwitchEntity): 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 @@ class LcnOutputSwitch(LcnEntity, SwitchEntity): ): 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 @@ class LcnRelaySwitch(LcnEntity, SwitchEntity): 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 @@ class LcnRelaySwitch(LcnEntity, SwitchEntity): 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 @@ class LcnRelaySwitch(LcnEntity, SwitchEntity): 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 @@ class LcnRelaySwitch(LcnEntity, SwitchEntity): 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 798a80147de..7971f6bfaf4 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.11.0", "ld2410-ble==0.1.1"] + "requirements": ["bluetooth-data-tools==1.12.0", "ld2410-ble==0.1.1"] } diff --git a/homeassistant/components/led_ble/manifest.json b/homeassistant/components/led_ble/manifest.json index da5b4b0a4ee..7b936eaad1a 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.11.0", "led-ble==1.0.0"] + "requirements": ["bluetooth-data-tools==1.12.0", "led-ble==1.0.1"] } diff --git a/homeassistant/components/life360/__init__.py b/homeassistant/components/life360/__init__.py index 271f934e1c7..c6e0fad14c6 100644 --- a/homeassistant/components/life360/__init__.py +++ b/homeassistant/components/life360/__init__.py @@ -39,7 +39,7 @@ from .const import ( ) 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 00000000000..07ef4d06ed9 --- /dev/null +++ b/homeassistant/components/life360/button.py @@ -0,0 +1,54 @@ +"""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 + ] + async_add_entities( + Life360UpdateLocationButton(coordinator, member.circle_id, member_id) + for member_id, member in coordinator.data.members.items() + ) + + +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 5ea64d3f81d..755fa1b8124 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 @@ class Life360DataUpdateCoordinator(DataUpdateCoordinator[Life360Data]): 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 @@ class Life360DataUpdateCoordinator(DataUpdateCoordinator[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 cc31ca64a08..343d9e95bb8 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 d6b253bd478..7cabfd4712f 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 f7f0150bdd2..cfcb1e13a07 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 e38ba888e71..00000000000 --- 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 6677768dd00..c1dfeda172c 100644 --- a/homeassistant/components/limitlessled/light.py +++ b/homeassistant/components/limitlessled/light.py @@ -182,20 +182,18 @@ def state(new_state): 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/litterrobot/manifest.json b/homeassistant/components/litterrobot/manifest.json index 9a3334cbaac..ea096a908fc 100644 --- a/homeassistant/components/litterrobot/manifest.json +++ b/homeassistant/components/litterrobot/manifest.json @@ -12,5 +12,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["pylitterbot"], - "requirements": ["pylitterbot==2023.4.5"] + "requirements": ["pylitterbot==2023.4.9"] } diff --git a/homeassistant/components/livisi/entity.py b/homeassistant/components/livisi/entity.py index 5ddba1e2e86..f76901ddb05 100644 --- a/homeassistant/components/livisi/entity.py +++ b/homeassistant/components/livisi/entity.py @@ -66,6 +66,7 @@ class LivisiEntity(CoordinatorEntity[LivisiDataUpdateCoordinator]): 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/logger/__init__.py b/homeassistant/components/logger/__init__.py index e7f3d6b78f1..32b864047a6 100644 --- a/homeassistant/components/logger/__init__.py +++ b/homeassistant/components/logger/__init__.py @@ -6,6 +6,7 @@ import re import voluptuous as vol +from homeassistant.const import EVENT_LOGGING_CHANGED # noqa: F401 from homeassistant.core import HomeAssistant, ServiceCall, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType @@ -14,7 +15,6 @@ from . import websocket_api from .const import ( ATTR_LEVEL, DOMAIN, - EVENT_LOGGING_CHANGED, # noqa: F401 LOGGER_DEFAULT, LOGGER_FILTERS, LOGGER_LOGS, diff --git a/homeassistant/components/logger/const.py b/homeassistant/components/logger/const.py index 06f2af4f3f5..4a7edfacead 100644 --- a/homeassistant/components/logger/const.py +++ b/homeassistant/components/logger/const.py @@ -35,8 +35,6 @@ LOGGER_FILTERS = "filters" ATTR_LEVEL = "level" -EVENT_LOGGING_CHANGED = "logging_changed" - STORAGE_KEY = "core.logger" STORAGE_LOG_KEY = "logs" STORAGE_VERSION = 1 diff --git a/homeassistant/components/logger/helpers.py b/homeassistant/components/logger/helpers.py index 49996408a1d..87ec2cc8cd5 100644 --- a/homeassistant/components/logger/helpers.py +++ b/homeassistant/components/logger/helpers.py @@ -9,6 +9,7 @@ from enum import StrEnum import logging from typing import Any, cast +from homeassistant.const import EVENT_LOGGING_CHANGED from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType @@ -16,7 +17,6 @@ from homeassistant.loader import IntegrationNotFound, async_get_integration from .const import ( DOMAIN, - EVENT_LOGGING_CHANGED, LOGGER_DEFAULT, LOGGER_LOGS, LOGSEVERITY, diff --git a/homeassistant/components/logi_circle/camera.py b/homeassistant/components/logi_circle/camera.py index 5c27d2a08ae..d1ea01e864c 100644 --- a/homeassistant/components/logi_circle/camera.py +++ b/homeassistant/components/logi_circle/camera.py @@ -71,10 +71,17 @@ class LogiCam(Camera): """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,22 +124,6 @@ class LogiCam(Camera): for detach in self._listeners: detach() - @property - def unique_id(self): - """Return a unique ID.""" - return self._id - - @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 32082b794b7..d06569a19ca 100644 --- a/homeassistant/components/logi_circle/sensor.py +++ b/homeassistant/components/logi_circle/sensor.py @@ -106,16 +106,12 @@ class LogiSensor(SensorEntity): 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 00000000000..4928d3bb164 --- /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 00000000000..2d3fd6b970f --- /dev/null +++ b/homeassistant/components/london_underground/coordinator.py @@ -0,0 +1,34 @@ +"""DataUpdateCoordinator for London underground integration.""" +from __future__ import annotations + +import asyncio +import logging +from typing import cast + +from london_tube_status import TubeData + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN, SCAN_INTERVAL + +_LOGGER = logging.getLogger(__name__) + + +class LondonTubeCoordinator(DataUpdateCoordinator[dict[str, dict[str, str]]]): + """London Underground sensor coordinator.""" + + def __init__(self, hass: HomeAssistant, data: TubeData) -> None: + """Initialize coordinator.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + self._data = data + + async def _async_update_data(self) -> dict[str, dict[str, str]]: + async with asyncio.timeout(10): + await self._data.update() + return cast(dict[str, dict[str, str]], self._data.data) diff --git a/homeassistant/components/london_underground/manifest.json b/homeassistant/components/london_underground/manifest.json index acdb83a2359..eafc63c6ae7 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 7e52186fa51..3f5ec42521e 100644 --- a/homeassistant/components/london_underground/sensor.py +++ b/homeassistant/components/london_underground/sensor.py @@ -1,9 +1,8 @@ """Sensor for checking the status of London Underground tube lines.""" from __future__ import annotations -import asyncio -from datetime import timedelta import logging +from typing import Any from london_tube_status import TubeData import voluptuous as vol @@ -15,37 +14,13 @@ 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.typing import ConfigType, DiscoveryInfoType -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import CONF_LINE, TUBE_LINES +from .coordinator import LondonTubeCoordinator _LOGGER = logging.getLogger(__name__) -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", -] - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( {vol.Required(CONF_LINE): vol.All(cv.ensure_list, [vol.In(list(TUBE_LINES))])} ) @@ -76,47 +51,28 @@ 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.""" _attr_attribution = "Powered by TfL Open Data" _attr_icon = "mdi:subway" - def __init__(self, coordinator, name): + def __init__(self, coordinator: LondonTubeCoordinator, name: str) -> None: """Initialize the London Underground sensor.""" super().__init__(coordinator) self._name = name @property - def name(self): + def name(self) -> str: """Return the name of the sensor.""" return self._name @property - def native_value(self): + def native_value(self) -> str: """Return the state of the sensor.""" return self.coordinator.data[self.name]["State"] @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return other details about the sensor state.""" return {"Description": self.coordinator.data[self.name]["Description"]} diff --git a/homeassistant/components/lookin/entity.py b/homeassistant/components/lookin/entity.py index d20a21bd23c..0e518ffc1e5 100644 --- a/homeassistant/components/lookin/entity.py +++ b/homeassistant/components/lookin/entity.py @@ -182,6 +182,7 @@ class LookinPowerPushRemoteEntity(LookinPowerEntity): 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 334590c0e65..da7d6106796 100644 --- a/homeassistant/components/lutron_caseta/binary_sensor.py +++ b/homeassistant/components/lutron_caseta/binary_sensor.py @@ -63,6 +63,7 @@ class LutronOccupancySensor(LutronCasetaDevice, BinarySensorEntity): """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/lyric/__init__.py b/homeassistant/components/lyric/__init__.py index 3e83fedb72a..d048b31d0b0 100644 --- a/homeassistant/components/lyric/__init__.py +++ b/homeassistant/components/lyric/__init__.py @@ -133,6 +133,7 @@ class LyricEntity(CoordinatorEntity[DataUpdateCoordinator[Lyric]]): 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 ef662d061e8..d0bad55ff14 100644 --- a/homeassistant/components/lyric/climate.py +++ b/homeassistant/components/lyric/climate.py @@ -14,6 +14,9 @@ import voluptuous as vol 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_HEAT = "Heat" 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 @@ HVAC_MODES = { 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 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity): ): 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 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity): ) 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 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity): """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 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity): 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 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity): 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 d628a108183..f0a4cdfbb99 100644 --- a/homeassistant/components/lyric/sensor.py +++ b/homeassistant/components/lyric/sensor.py @@ -4,7 +4,6 @@ from __future__ import annotations 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 @@ LYRIC_SETPOINT_STATUS_NAMES = { @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: + for device_sensor in DEVICE_SENSORS: + if device_sensor.suitable_fn(device): 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: - 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 @@ class LyricSensor(LyricDeviceEntity, SensorEntity): 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 febafc367f1..cf7bcce7b3c 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 +import re +from typing import NewType, TypedDict -from matrix_client.client import MatrixClient, MatrixRequestError +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 @@ from homeassistant.const import ( 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_COMMANDS = "commands" 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 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) - SERVICE_SCHEMA_SEND_MESSAGE = vol.Schema( { vol.Required(ATTR_MESSAGE): cv.string, @@ -90,30 +121,26 @@ SERVICE_SCHEMA_SEND_MESSAGE = vol.Schema( ) -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._client = AsyncClient( + homeserver=self._homeserver, user=self._mx_id, ssl=self._verify_tls + ) + 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() + self._word_commands: dict[RoomID, dict[WordCommand, ConfigCommand]] = {} + self._expression_commands: dict[RoomID, list[ConfigCommand]] = {} + self._load_commands(commands) - # Word commands are stored dict-of-dict: First dict indexes by room ID - # / alias, second dict indexes by the word - self._word_commands = {} + async def stop_client(event: HassEvent) -> None: + """Run once when Home Assistant stops.""" + if self._client is not None: + await self._client.close() - # Regular expression commands are stored as a list of commands per - # room, i.e., a dict-of-list - self._expression_commands = {} + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_client) + 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) + + self._client.add_event_callback(self._handle_room_message, RoomMessageText) + + await self._client.sync_forever( + timeout=30_000, + loop_sleep_time=1_000, + ) # milliseconds. + + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, handle_startup) + + def _load_commands(self, commands: list[ConfigCommand]) -> None: for command in commands: - if not command.get(CONF_ROOMS): - command[CONF_ROOMS] = listening_rooms + # Set the command for all listening_rooms, unless otherwise specified. + command.setdefault(CONF_ROOMS, self._listening_rooms) # type: ignore[misc] - 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 + # 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]: - if room_id not in self._expression_commands: - self._expression_commands[room_id] = [] + for room_id in command[CONF_ROOMS]: # type: ignore[literal-required] + self._expression_commands.setdefault(room_id, []) self._expression_commands[room_id].append(command) - # Log in. This raises a MatrixRequestError if login is unsuccessful - self._client = self._login() - - def handle_matrix_exception(exception): - """Handle exceptions raised inside the Matrix SDK.""" - _LOGGER.error("Matrix exception:\n %s", str(exception)) - - self._client.start_listener_thread(exception_handler=handle_matrix_exception) - - def stop_client(_): - """Run once when Home Assistant stops.""" - self._client.stop_listener_thread() - - self.hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_client) - - # 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.listen_once(EVENT_HOMEASSISTANT_START, handle_startup) - - 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) + self.hass.bus.async_fire(EVENT_MATRIX_COMMAND, message_data) - def _join_or_get_room(self, room_id_or_alias): - """Join a room or get it, if we are already in the room. + 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) - 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] + 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, + ) - 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 - - 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" - ) + rooms = [ + self.hass.async_create_task(self._join_room(room_id)) + for room_id in self._listening_rooms + ] + await asyncio.wait(rooms) - 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. - """ + 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 @@ class MatrixBot: ) 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") + # 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") - except MatrixRequestError as ex: - _LOGGER.error( - "Login failed, both token and username/password invalid: %d, %s", - ex.code, - ex.content, + 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 + if not self._client.logged_in: + raise ConfigEntryAuthFailed( + "Login failed, both token and username/password are invalid" + ) - 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, + 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) - 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 + 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) + + 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 ) - - _client.login_with_password(self._mx_id, self._password) - - self._store_auth_token(_client.token) - - return _client - - def _send_image(self, img, target_rooms): - _LOGGER.debug("Uploading file from path, %s", img) - - if not self.hass.config.is_allowed_path(img): - _LOGGER.error("Path not allowed: %s", img) + if not _is_allowed_path: + _LOGGER.error("Path not allowed: %s", image_path) 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, - ) - 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) + # 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) - 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), - service.data[ATTR_TARGET], + _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 + + 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, + } + + await self._handle_multi_room_send( + target_rooms=target_rooms, message_type="m.room.message", content=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} + + await self._handle_multi_room_send( + target_rooms=target_rooms, message_type="m.room.message", content=content + ) + + 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.""" + 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 4bded80a711..69d059fdce5 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.1"] } diff --git a/homeassistant/components/matrix/notify.py b/homeassistant/components/matrix/notify.py index 3c90e9afbc0..c71f91eb582 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 @@ from homeassistant.core import HomeAssistant 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/medcom_ble/__init__.py b/homeassistant/components/medcom_ble/__init__.py new file mode 100644 index 00000000000..a129c4fc7f9 --- /dev/null +++ b/homeassistant/components/medcom_ble/__init__.py @@ -0,0 +1,74 @@ +"""The Medcom BLE integration.""" +from __future__ import annotations + +from datetime import timedelta +import logging + +from bleak import BleakError +from medcom_ble import MedcomBleDeviceData + +from homeassistant.components import bluetooth +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util.unit_system import METRIC_SYSTEM + +from .const import DEFAULT_SCAN_INTERVAL, DOMAIN + +# Supported platforms +PLATFORMS: list[Platform] = [Platform.SENSOR] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Medcom BLE radiation monitor from a config entry.""" + + address = entry.unique_id + elevation = hass.config.elevation + is_metric = hass.config.units is METRIC_SYSTEM + assert address is not None + + ble_device = bluetooth.async_ble_device_from_address(hass, address) + if not ble_device: + raise ConfigEntryNotReady( + f"Could not find Medcom BLE device with address {address}" + ) + + async def _async_update_method(): + """Get data from Medcom BLE radiation monitor.""" + ble_device = bluetooth.async_ble_device_from_address(hass, address) + inspector = MedcomBleDeviceData(_LOGGER, elevation, is_metric) + + try: + data = await inspector.update_device(ble_device) + except BleakError as err: + raise UpdateFailed(f"Unable to fetch data: {err}") from err + + return data + + coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name=DOMAIN, + update_method=_async_update_method, + update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL), + ) + + 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/medcom_ble/config_flow.py b/homeassistant/components/medcom_ble/config_flow.py new file mode 100644 index 00000000000..30a87afbb72 --- /dev/null +++ b/homeassistant/components/medcom_ble/config_flow.py @@ -0,0 +1,147 @@ +"""Config flow for Medcom BlE integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from bleak import BleakError +from bluetooth_data_tools import human_readable_name +from medcom_ble import MedcomBleDevice, MedcomBleDeviceData +from medcom_ble.const import INSPECTOR_SERVICE_UUID +import voluptuous as vol + +from homeassistant.components import bluetooth +from homeassistant.components.bluetooth import ( + BluetoothServiceInfo, + async_discovered_service_info, +) +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_ADDRESS +from homeassistant.data_entry_flow import AbortFlow, FlowResult + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class InspectorBLEConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Medcom BLE radiation monitors.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize the config flow.""" + self._discovery_info: BluetoothServiceInfo | None = None + self._discovered_devices: dict[str, BluetoothServiceInfo] = {} + + async def _get_device_data( + self, service_info: BluetoothServiceInfo + ) -> MedcomBleDevice: + ble_device = bluetooth.async_ble_device_from_address( + self.hass, service_info.address + ) + if ble_device is None: + _LOGGER.debug("no ble_device in _get_device_data") + raise AbortFlow("cannot_connect") + + inspector = MedcomBleDeviceData(_LOGGER) + + return await inspector.update_device(ble_device) + + async def async_step_bluetooth( + self, discovery_info: BluetoothServiceInfo + ) -> FlowResult: + """Handle the bluetooth discovery step.""" + _LOGGER.debug("Discovered BLE device: %s", discovery_info.name) + 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_bluetooth_confirm() + + async def async_step_bluetooth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Confirm discovery.""" + # We always will have self._discovery_info be a BluetoothServiceInfo at this point + # and this helps mypy not complain + assert self._discovery_info is not None + + if user_input is None: + name = self._discovery_info.name or self._discovery_info.address + return self.async_show_form( + step_id="bluetooth_confirm", + description_placeholders={"name": name}, + ) + + return await self.async_step_check_connection() + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the user step to pick discovered device.""" + if user_input is not None: + address = user_input[CONF_ADDRESS] + await self.async_set_unique_id(address, raise_on_progress=False) + self._abort_if_unique_id_configured() + self._discovery_info = self._discovered_devices[address] + return await self.async_step_check_connection() + + current_addresses = self._async_current_ids() + for discovery_info in async_discovered_service_info(self.hass): + address = discovery_info.address + if address in current_addresses or address in self._discovered_devices: + _LOGGER.debug( + "Detected a device that's already configured: %s", address + ) + continue + + if INSPECTOR_SERVICE_UUID not in discovery_info.service_uuids: + continue + + self._discovered_devices[discovery_info.address] = discovery_info + + if not self._discovered_devices: + return self.async_abort(reason="no_devices_found") + + titles = { + address: discovery.name + for address, discovery in self._discovered_devices.items() + } + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_ADDRESS): vol.In(titles), + }, + ), + ) + + async def async_step_check_connection(self) -> FlowResult: + """Check we can connect to the device before considering the configuration is successful.""" + # We always will have self._discovery_info be a BluetoothServiceInfo at this point + # and this helps mypy not complain + assert self._discovery_info is not None + + _LOGGER.debug("Checking device connection: %s", self._discovery_info.name) + try: + await self._get_device_data(self._discovery_info) + except BleakError: + return self.async_abort(reason="cannot_connect") + except AbortFlow: + raise + except Exception as err: # pylint: disable=broad-except + _LOGGER.exception( + "Error occurred reading information from %s: %s", + self._discovery_info.address, + err, + ) + return self.async_abort(reason="unknown") + _LOGGER.debug("Device connection successful, proceeding") + return self.async_create_entry(title=self._discovery_info.name, data={}) diff --git a/homeassistant/components/medcom_ble/const.py b/homeassistant/components/medcom_ble/const.py new file mode 100644 index 00000000000..3929b5d302b --- /dev/null +++ b/homeassistant/components/medcom_ble/const.py @@ -0,0 +1,10 @@ +"""Constants for the Medcom BLE integration.""" + +DOMAIN = "medcom_ble" + +# 5 minutes scan interval, which is perfectly +# adequate for background monitoring +DEFAULT_SCAN_INTERVAL = 300 + +# Units for the radiation monitors +UNIT_CPM = "CPM" diff --git a/homeassistant/components/medcom_ble/manifest.json b/homeassistant/components/medcom_ble/manifest.json new file mode 100644 index 00000000000..4aacae4647d --- /dev/null +++ b/homeassistant/components/medcom_ble/manifest.json @@ -0,0 +1,15 @@ +{ + "domain": "medcom_ble", + "name": "Medcom Bluetooth", + "bluetooth": [ + { + "service_uuid": "39b31fec-b63a-4ef7-b163-a7317872007f" + } + ], + "codeowners": ["@elafargue"], + "config_flow": true, + "dependencies": ["bluetooth_adapters"], + "documentation": "https://www.home-assistant.io/integrations/medcom_ble", + "iot_class": "local_polling", + "requirements": ["medcom-ble==0.1.1"] +} diff --git a/homeassistant/components/medcom_ble/sensor.py b/homeassistant/components/medcom_ble/sensor.py new file mode 100644 index 00000000000..4c7488ddc12 --- /dev/null +++ b/homeassistant/components/medcom_ble/sensor.py @@ -0,0 +1,104 @@ +"""Support for Medcom BLE radiation monitor sensors.""" +from __future__ import annotations + +import logging + +from medcom_ble import MedcomBleDevice + +from homeassistant import config_entries +from homeassistant.components.sensor import ( + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from .const import DOMAIN, UNIT_CPM + +_LOGGER = logging.getLogger(__name__) + +SENSORS_MAPPING_TEMPLATE: dict[str, SensorEntityDescription] = { + "cpm": SensorEntityDescription( + key="cpm", + translation_key="cpm", + native_unit_of_measurement=UNIT_CPM, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:radioactive", + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: config_entries.ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Medcom BLE radiation monitor sensors.""" + + coordinator: DataUpdateCoordinator[MedcomBleDevice] = hass.data[DOMAIN][ + entry.entry_id + ] + + entities = [] + _LOGGER.debug("got sensors: %s", coordinator.data.sensors) + for sensor_type, sensor_value in coordinator.data.sensors.items(): + if sensor_type not in SENSORS_MAPPING_TEMPLATE: + _LOGGER.debug( + "Unknown sensor type detected: %s, %s", + sensor_type, + sensor_value, + ) + continue + entities.append( + MedcomSensor(coordinator, SENSORS_MAPPING_TEMPLATE[sensor_type]) + ) + + async_add_entities(entities) + + +class MedcomSensor( + CoordinatorEntity[DataUpdateCoordinator[MedcomBleDevice]], SensorEntity +): + """Medcom BLE radiation monitor sensors for the device.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: DataUpdateCoordinator[MedcomBleDevice], + entity_description: SensorEntityDescription, + ) -> None: + """Populate the medcom entity with relevant data.""" + super().__init__(coordinator) + self.entity_description = entity_description + medcom_device = coordinator.data + + name = medcom_device.name + if identifier := medcom_device.identifier: + name += f" ({identifier})" + + self._attr_unique_id = f"{medcom_device.address}_{entity_description.key}" + self._attr_device_info = DeviceInfo( + connections={ + ( + CONNECTION_BLUETOOTH, + medcom_device.address, + ) + }, + name=name, + manufacturer=medcom_device.manufacturer, + hw_version=medcom_device.hw_version, + sw_version=medcom_device.sw_version, + model=medcom_device.model, + ) + + @property + def native_value(self) -> float: + """Return the value reported by the sensor.""" + return self.coordinator.data.sensors[self.entity_description.key] diff --git a/homeassistant/components/medcom_ble/strings.json b/homeassistant/components/medcom_ble/strings.json new file mode 100644 index 00000000000..6ea6c0566ed --- /dev/null +++ b/homeassistant/components/medcom_ble/strings.json @@ -0,0 +1,30 @@ +{ + "config": { + "flow_title": "[%key:component::bluetooth::config::flow_title%]", + "step": { + "user": { + "description": "[%key:component::bluetooth::config::step::user::description%]", + "data": { + "address": "[%key:component::bluetooth::config::step::user::data::address%]" + } + }, + "bluetooth_confirm": { + "description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]" + } + }, + "abort": { + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + }, + "entity": { + "sensor": { + "cpm": { + "name": "Counts per minute" + } + } + } +} diff --git a/homeassistant/components/media_extractor/__init__.py b/homeassistant/components/media_extractor/__init__.py index dae734fc06f..328871cf78c 100644 --- a/homeassistant/components/media_extractor/__init__.py +++ b/homeassistant/components/media_extractor/__init__.py @@ -135,11 +135,10 @@ class MediaExtractor: raise MEQueryException() from err if "formats" in requested_stream: - best_stream = requested_stream["formats"][ - len(requested_stream["formats"]) - 1 - ] - return str(best_stream["url"]) - return str(requested_stream["url"]) + if requested_stream["extractor"] == "youtube": + return get_best_stream_youtube(requested_stream["formats"]) + return get_best_stream(requested_stream["formats"]) + return cast(str, requested_stream["url"]) return stream_selector @@ -154,7 +153,7 @@ class MediaExtractor: except MEQueryException: _LOGGER.error("Wrong query format: %s", stream_query) return - + _LOGGER.debug("Selected the following stream: %s", stream_url) data = {k: v for k, v in self.call_data.items() if k != ATTR_ENTITY_ID} data[ATTR_MEDIA_CONTENT_ID] = stream_url @@ -181,3 +180,29 @@ class MediaExtractor: ) return default_stream_query + + +def get_best_stream(formats: list[dict[str, Any]]) -> str: + """Return the best quality stream. + + As per + https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/extractor/common.py#L128. + """ + + return cast(str, formats[len(formats) - 1]["url"]) + + +def get_best_stream_youtube(formats: list[dict[str, Any]]) -> str: + """YouTube responses also include files with only video or audio. + + So we filter on files with both audio and video codec. + """ + + return get_best_stream( + [ + format + for format in formats + if format.get("acodec", "none") != "none" + and format.get("vcodec", "none") != "none" + ] + ) diff --git a/homeassistant/components/media_extractor/manifest.json b/homeassistant/components/media_extractor/manifest.json index 707cbdf9e8b..37a8a0d6773 100644 --- a/homeassistant/components/media_extractor/manifest.json +++ b/homeassistant/components/media_extractor/manifest.json @@ -7,5 +7,5 @@ "iot_class": "calculated", "loggers": ["yt_dlp"], "quality_scale": "internal", - "requirements": ["yt-dlp==2023.7.6"] + "requirements": ["yt-dlp==2023.9.24"] } diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index 2acb516fa95..f3ff925a1a4 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -27,6 +27,7 @@ from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView 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 8ced833ebec..00000000000 --- 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 a5a0d34d4eb..a1cc1ade8e1 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 @@ class MetWeather(SingleCoordinatorWeatherEntity[MetDataUpdateCoordinator]): 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 @@ class MetWeather(SingleCoordinatorWeatherEntity[MetDataUpdateCoordinator]): 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 @@ class MetWeather(SingleCoordinatorWeatherEntity[MetDataUpdateCoordinator]): 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 3a45a74c36b..7602dca8343 100644 --- a/homeassistant/components/met_eireann/weather.py +++ b/homeassistant/components/met_eireann/weather.py @@ -94,24 +94,20 @@ class MetEireannWeather( 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 @@ class MetEireannWeather( 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/meteoclimatic/sensor.py b/homeassistant/components/meteoclimatic/sensor.py index ed37c6d98ea..9a54e766945 100644 --- a/homeassistant/components/meteoclimatic/sensor.py +++ b/homeassistant/components/meteoclimatic/sensor.py @@ -3,6 +3,7 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -29,6 +30,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( name="Temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="temp_max", @@ -47,6 +49,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( name="Humidity", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="humidity_max", @@ -65,6 +68,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( name="Pressure", native_unit_of_measurement=UnitOfPressure.HPA, device_class=SensorDeviceClass.PRESSURE, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="pressure_max", @@ -83,6 +87,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( 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 ec47d98b7a9..582450eca62 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/climate.py b/homeassistant/components/mill/climate.py index 2ddcf97f25a..a5e59b4f8ec 100644 --- a/homeassistant/components/mill/climate.py +++ b/homeassistant/components/mill/climate.py @@ -2,6 +2,7 @@ from typing import Any import mill +from mill_local import OperationMode import voluptuous as vol from homeassistant.components.climate import ( @@ -176,8 +177,7 @@ class LocalMillHeater(CoordinatorEntity[MillDataUpdateCoordinator], ClimateEntit """Representation of a Mill Thermostat device.""" _attr_has_entity_name = True - _attr_hvac_mode = HVACMode.HEAT - _attr_hvac_modes = [HVACMode.HEAT] + _attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF] _attr_max_temp = MAX_TEMP _attr_min_temp = MIN_TEMP _attr_name = None @@ -210,6 +210,15 @@ class LocalMillHeater(CoordinatorEntity[MillDataUpdateCoordinator], ClimateEntit ) await self.coordinator.async_request_refresh() + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set new target hvac mode.""" + if hvac_mode == HVACMode.HEAT: + await self.coordinator.mill_data_connection.set_operation_mode_control_individually() + await self.coordinator.async_request_refresh() + elif hvac_mode == HVACMode.OFF: + await self.coordinator.mill_data_connection.set_operation_mode_off() + await self.coordinator.async_request_refresh() + @callback def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" @@ -222,7 +231,12 @@ class LocalMillHeater(CoordinatorEntity[MillDataUpdateCoordinator], ClimateEntit self._attr_target_temperature = data["set_temperature"] self._attr_current_temperature = data["ambient_temperature"] - if data["current_power"] > 0: - self._attr_hvac_action = HVACAction.HEATING + if data["operation_mode"] == OperationMode.OFF.value: + self._attr_hvac_mode = HVACMode.OFF + self._attr_hvac_action = HVACAction.OFF else: - self._attr_hvac_action = HVACAction.IDLE + self._attr_hvac_mode = HVACMode.HEAT + if data["current_power"] > 0: + self._attr_hvac_action = HVACAction.HEATING + else: + self._attr_hvac_action = HVACAction.IDLE diff --git a/homeassistant/components/mill/manifest.json b/homeassistant/components/mill/manifest.json index 7e9416f6695..cb0ba4522bf 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.5", "mill-local==0.2.0"] + "requirements": ["millheater==0.11.6", "mill-local==0.3.0"] } diff --git a/homeassistant/components/mill/sensor.py b/homeassistant/components/mill/sensor.py index 47b5b8c7b64..8c7c418e8ff 100644 --- a/homeassistant/components/mill/sensor.py +++ b/homeassistant/components/mill/sensor.py @@ -44,17 +44,17 @@ from .const import ( HEATER_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key=CONSUMPTION_YEAR, + translation_key="year_consumption", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, - name="Year consumption", ), SensorEntityDescription( key=CONSUMPTION_TODAY, + translation_key="day_consumption", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, - name="Day consumption", ), ) @@ -63,21 +63,18 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( key=TEMPERATURE, device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, - name="Temperature", state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=HUMIDITY, device_class=SensorDeviceClass.HUMIDITY, native_unit_of_measurement=PERCENTAGE, - name="Humidity", state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=BATTERY, device_class=SensorDeviceClass.BATTERY, native_unit_of_measurement=PERCENTAGE, - name="Battery", state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ), @@ -85,13 +82,13 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( key=ECO2, device_class=SensorDeviceClass.CO2, native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, - name="Estimated CO2", + translation_key="estimated_co2", state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=TVOC, native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, - name="TVOC", + translation_key="tvoc", state_class=SensorStateClass.MEASUREMENT, ), ) @@ -99,22 +96,22 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( LOCAL_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="control_signal", + translation_key="control_signal", native_unit_of_measurement=PERCENTAGE, - name="Control signal", state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="current_power", + translation_key="current_power", device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.WATT, - name="Current power", state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="raw_ambient_temperature", + translation_key="uncalibrated_temperature", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, - name="Uncalibrated temperature", state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), @@ -159,6 +156,8 @@ async def async_setup_entry( class MillSensor(CoordinatorEntity, SensorEntity): """Representation of a Mill Sensor device.""" + _attr_has_entity_name = True + def __init__(self, coordinator, entity_description, mill_device): """Initialize the sensor.""" super().__init__(coordinator) @@ -166,8 +165,6 @@ class MillSensor(CoordinatorEntity, SensorEntity): self._id = mill_device.device_id self.entity_description = entity_description self._available = False - - self._attr_name = f"{mill_device.name} {entity_description.name}" self._attr_unique_id = f"{mill_device.device_id}_{entity_description.key}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, mill_device.device_id)}, @@ -197,14 +194,13 @@ class MillSensor(CoordinatorEntity, SensorEntity): class LocalMillSensor(CoordinatorEntity, SensorEntity): """Representation of a Mill Sensor device.""" + _attr_has_entity_name = True + def __init__(self, coordinator, entity_description): """Initialize the sensor.""" super().__init__(coordinator) self.entity_description = entity_description - self._attr_name = ( - f"{coordinator.mill_data_connection.name} {entity_description.name}" - ) if mac := coordinator.mill_data_connection.mac_address: self._attr_unique_id = f"{mac}_{entity_description.key}" self._attr_device_info = DeviceInfo( diff --git a/homeassistant/components/mill/strings.json b/homeassistant/components/mill/strings.json index caeea189c0e..21e3e7a44a5 100644 --- a/homeassistant/components/mill/strings.json +++ b/homeassistant/components/mill/strings.json @@ -27,6 +27,31 @@ } } }, + "entity": { + "sensor": { + "year_consumption": { + "name": "Year consumption" + }, + "day_consumption": { + "name": "Day consumption" + }, + "estimated_co2": { + "name": "Estimated CO2" + }, + "tvoc": { + "name": "TVOC" + }, + "control_signal": { + "name": "Control signal" + }, + "current_power": { + "name": "Current power" + }, + "uncalibrated_temperature": { + "name": "Uncalibrated temperature" + } + } + }, "services": { "set_room_temperature": { "name": "Set room temperature", diff --git a/homeassistant/components/minecraft_server/__init__.py b/homeassistant/components/minecraft_server/__init__.py index cf0d96af8d2..7f2b08c96ef 100644 --- a/homeassistant/components/minecraft_server/__init__.py +++ b/homeassistant/components/minecraft_server/__init__.py @@ -1,22 +1,19 @@ """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 mcstatus 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_ADDRESS, CONF_HOST, CONF_PORT, 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 +22,14 @@ _LOGGER = logging.getLogger(__name__) 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)", - 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. + coordinator = MinecraftServerCoordinator(hass, entry) + await coordinator.async_config_entry_first_refresh() + + # Store coordinator instance. + domain_data = hass.data.setdefault(DOMAIN, {}) + domain_data[entry.entry_id] = coordinator # Set up platforms. await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -48,8 +39,6 @@ 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] # Unload platforms. unload_ok = await hass.config_entries.async_unload_platforms( @@ -57,164 +46,148 @@ 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.entry_id) return unload_ok -@dataclass -class MinecraftServerData: - """Representation of Minecraft server data.""" +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate old config entry to a new format.""" - 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 + # 1 --> 2: Use config entry ID as base for unique IDs. + if config_entry.version == 1: + _LOGGER.debug("Migrating from version 1") + old_unique_id = config_entry.unique_id + assert old_unique_id + config_entry_id = config_entry.entry_id -class MinecraftServer: - """Representation of a Minecraft server.""" + # 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) - def __init__( - self, hass: HomeAssistant, unique_id: str, config_data: Mapping[str, Any] - ) -> None: - """Initialize server instance.""" - self._hass = hass + # Migrate device. + await _async_migrate_device_identifiers(hass, config_entry, old_unique_id) - # 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 + # Migrate entities. + await er.async_migrate_entries(hass, config_entry_id, _migrate_entity_unique_id) - # 3rd party library instance - self._server = JavaServer(self.host, self.port) + _LOGGER.debug("Migration to version 2 successful") - # Data provided by 3rd party library - self.data: MinecraftServerData = MinecraftServerData() + # 2 --> 3: Use address instead of host and port in config entry. + if config_entry.version == 2: + _LOGGER.debug("Migrating from version 2") - # Dispatcher signal name - self.signal_name = f"{SIGNAL_NAME_PREFIX}_{self.unique_id}" + config_data = config_entry.data - # 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: - _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. + # Migrate config entry. try: - await self._server.async_status() - self.online = True - except OSError as error: + address = config_data[CONF_HOST] + JavaServer.lookup(address) + host_only_lookup_success = True + except ValueError as error: + host_only_lookup_success = False _LOGGER.debug( - ( - "Error occurred while trying to check the connection to '%s:%s' -" - " OSError: %s" - ), - self.host, - self.port, + "Hostname (without port) cannot be parsed (error: %s), trying again with 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, - ) - 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, + if not host_only_lookup_success: + try: + address = f"{config_data[CONF_HOST]}:{config_data[CONF_PORT]}" + JavaServer.lookup(address) + except ValueError as error: + _LOGGER.exception( + "Can't migrate configuration entry due to error while parsing server address (error: %s), try again later", error, ) - self._last_status_request_failed = True + return False + + _LOGGER.debug( + "Migrating config entry, replacing host '%s' and port '%s' with address '%s'", + config_data[CONF_HOST], + config_data[CONF_PORT], + address, + ) + + new_data = config_data.copy() + new_data[CONF_ADDRESS] = address + del new_data[CONF_HOST] + del new_data[CONF_PORT] + config_entry.version = 3 + hass.config_entries.async_update_entry(config_entry, data=new_data) + + _LOGGER.debug("Migration to version 3 successful") + + 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( + "Migrating device identifiers from %s to %s", + device_entry.identifiers, + new_identifiers, + ) + device_registry.async_update_device( + device_id=device_entry.id, new_identifiers=new_identifiers + ) + # 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 3589bfab3e2..e89fce2d7d5 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 c8429284cd8..527dfa1ed04 100644 --- a/homeassistant/components/minecraft_server/config_flow.py +++ b/homeassistant/components/minecraft_server/config_flow.py @@ -1,118 +1,39 @@ """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.const import CONF_ADDRESS, CONF_NAME from homeassistant.data_entry_flow import FlowResult -from . import MinecraftServer, helpers -from .const import DEFAULT_HOST, DEFAULT_NAME, DEFAULT_PORT, DOMAIN +from .const import DEFAULT_NAME, DOMAIN + +DEFAULT_ADDRESS = "localhost:25565" + +_LOGGER = logging.getLogger(__name__) class MinecraftServerConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Minecraft Server.""" - VERSION = 1 + VERSION = 3 async def async_step_user(self, user_input=None) -> FlowResult: """Handle the initial step.""" errors = {} - if user_input is not None: - host = None - port = DEFAULT_PORT - # 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 - else: - host = address_left - with suppress(ValueError): - port = int(address_right) + if user_input: + address = user_input[CONF_ADDRESS] - # Remove '[' and ']' in case of an IPv6 address. - host = host.strip("[]") + if await self._async_is_server_online(address): + # No error was detected, create configuration entry. + config_data = {CONF_NAME: user_input[CONF_NAME], CONF_ADDRESS: address} + return self.async_create_entry(title=address, data=config_data) - # 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): - errors["base"] = "invalid_port" - # Validate host and port by checking the server connection. - else: - # Create server instance with configuration data and ping the server. - config_data = { - CONF_NAME: user_input[CONF_NAME], - 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() - - # 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). @@ -131,9 +52,44 @@ class MinecraftServerConfigFlow(ConfigFlow, domain=DOMAIN): CONF_NAME, default=user_input.get(CONF_NAME, DEFAULT_NAME) ): str, vol.Required( - CONF_HOST, default=user_input.get(CONF_HOST, DEFAULT_HOST) + CONF_ADDRESS, + default=user_input.get(CONF_ADDRESS, DEFAULT_ADDRESS), ): vol.All(str, vol.Lower), } ), errors=errors, ) + + async def _async_is_server_online(self, address: str) -> bool: + """Check server connection using a 'status' request and return result.""" + + # Parse and check server address. + try: + server = await JavaServer.async_lookup(address) + except ValueError as error: + _LOGGER.debug( + ( + "Error occurred while parsing server address '%s' -" + " ValueError: %s" + ), + address, + error, + ) + return False + + # Send a status request to the server. + 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" + ), + server.address.host, + server.address.port, + error, + ) + + return False diff --git a/homeassistant/components/minecraft_server/const.py b/homeassistant/components/minecraft_server/const.py index 72a891138c4..e7a58741696 100644 --- a/homeassistant/components/minecraft_server/const.py +++ b/homeassistant/components/minecraft_server/const.py @@ -1,44 +1,8 @@ """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 00000000000..9b5ab1fbb43 --- /dev/null +++ b/homeassistant/components/minecraft_server/coordinator.py @@ -0,0 +1,77 @@ +"""The Minecraft Server integration.""" +from __future__ import annotations + +from dataclasses import dataclass +from datetime import timedelta +import logging + +from mcstatus.server import JavaServer + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ADDRESS, CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +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.""" + + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + """Initialize coordinator instance.""" + config_data = config_entry.data + self.unique_id = config_entry.entry_id + + super().__init__( + hass=hass, + name=config_data[CONF_NAME], + logger=_LOGGER, + update_interval=SCAN_INTERVAL, + ) + + try: + self._server = JavaServer.lookup(config_data[CONF_ADDRESS]) + except ValueError as error: + raise HomeAssistantError( + f"Address in configuration entry cannot be parsed (error: {error}), please remove this device and add it again" + ) from error + + async def _async_update_data(self) -> MinecraftServerData: + """Get server data from 3rd party library and update properties.""" + try: + status_response = await self._server.async_status() + except OSError as error: + raise UpdateFailed(error) from error + + 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 63d68d0aa77..9bac71e0000 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}", + model=f"Minecraft Server ({coordinator.data.version})", + name=coordinator.name, + sw_version=str(coordinator.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 - ) - - 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 deleted file mode 100644 index d4a49d96f83..00000000000 --- a/homeassistant/components/minecraft_server/helpers.py +++ /dev/null @@ -1,35 +0,0 @@ -"""Helper functions for the Minecraft Server integration.""" -from __future__ import annotations - -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 - - -async def async_check_srv_record( - hass: HomeAssistant, 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 - try: - srv_records = await aiodns.DNSResolver().query( - host=f"{SRV_RECORD_PREFIX}.{host}", qtype="SRV" - ) - except aiodns.error.DNSError: - # 'host' is not a 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, - } - - return return_value diff --git a/homeassistant/components/minecraft_server/manifest.json b/homeassistant/components/minecraft_server/manifest.json index 27019cb80a8..6f11d34cccb 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": ["mcstatus==11.0.0"] } diff --git a/homeassistant/components/minecraft_server/sensor.py b/homeassistant/components/minecraft_server/sensor.py index 74422675718..efe534e0f92 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 + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.unique_id}-{description.key}" + self._update_properties() - @property - def available(self) -> bool: - """Return sensor availability.""" - return self._server.online + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._update_properties() + self.async_write_ha_state() - -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, + @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 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, - ) - - 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 b4d68bc6117..c5fe5b81d81 100644 --- a/homeassistant/components/minecraft_server/strings.json +++ b/homeassistant/components/minecraft_server/strings.json @@ -6,17 +6,12 @@ "description": "Set up your Minecraft Server instance to allow monitoring.", "data": { "name": "[%key:common::config_flow::data::name%]", - "host": "[%key:common::config_flow::data::host%]" + "address": "Server address" } } }, "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 address and try again. If a port was provided, it must be within a valid range. Also ensure that you are running at least version 1.7 of Minecraft Java Edition on your server." } }, "entity": { diff --git a/homeassistant/components/mobile_app/helpers.py b/homeassistant/components/mobile_app/helpers.py index e8460b721a2..e9bb3af51f2 100644 --- a/homeassistant/components/mobile_app/helpers.py +++ b/homeassistant/components/mobile_app/helpers.py @@ -3,7 +3,6 @@ from __future__ import annotations from collections.abc import Callable, Mapping from http import HTTPStatus -import json import logging from typing import Any @@ -14,7 +13,7 @@ from nacl.secret import SecretBox 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/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index cb36661d711..85fba66b68a 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -62,6 +62,7 @@ from .const import ( # noqa: F401 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 @@ from .const import ( # noqa: F401 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 @@ BASE_COMPONENT_SCHEMA = vol.Schema( { 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 @@ BASE_STRUCT_SCHEMA = BASE_COMPONENT_SCHEMA.extend( DataType.FLOAT32, DataType.FLOAT64, DataType.STRING, - DataType.STRING, DataType.CUSTOM, ] ), @@ -309,7 +311,8 @@ SENSOR_SCHEMA = vol.All( 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 @@ BINARY_SENSOR_SCHEMA = BASE_COMPONENT_SCHEMA.extend( 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 @@ MODBUS_SCHEMA = vol.Schema( { 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 f1a48728814..ee98b51b72a 100644 --- a/homeassistant/components/modbus/base_platform.py +++ b/homeassistant/components/modbus/base_platform.py @@ -42,6 +42,7 @@ from .const import ( 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 @@ from .const import ( 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 @@ -162,8 +164,12 @@ class BaseStructPlatform(BasePlatform, RestoreEntity): 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]: diff --git a/homeassistant/components/modbus/binary_sensor.py b/homeassistant/components/modbus/binary_sensor.py index 05668bac0a9..39174ae8931 100644 --- a/homeassistant/components/modbus/binary_sensor.py +++ b/homeassistant/components/modbus/binary_sensor.py @@ -24,7 +24,12 @@ from homeassistant.helpers.update_coordinator import ( 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 @@ class ModbusBinarySensor(BasePlatform, RestoreEntity, BinarySensorEntity): 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 3acf8d7ac29..df2983e9070 100644 --- a/homeassistant/components/modbus/climate.py +++ b/homeassistant/components/modbus/climate.py @@ -247,10 +247,6 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): # 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 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): 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 e509577267c..92a38bb5e92 100644 --- a/homeassistant/components/modbus/const.py +++ b/homeassistant/components/modbus/const.py @@ -17,6 +17,7 @@ CONF_BYTESIZE = "bytesize" 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_DRY = "state_dry" 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 3c4247c61fb..27f9cb1fc18 100644 --- a/homeassistant/components/modbus/cover.py +++ b/homeassistant/components/modbus/cover.py @@ -138,14 +138,9 @@ class ModbusCover(BasePlatform, CoverEntity, RestoreEntity): """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 b70055e5fbe..7faf873b655 100644 --- a/homeassistant/components/modbus/manifest.json +++ b/homeassistant/components/modbus/manifest.json @@ -1,7 +1,7 @@ { "domain": "modbus", "name": "Modbus", - "codeowners": ["@adamchengtkc", "@janiversen", "@vzahradnik"], + "codeowners": ["@janiversen"], "documentation": "https://www.home-assistant.io/integrations/modbus", "iot_class": "local_polling", "loggers": ["pymodbus"], diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index fdb7be3d3cf..4ef205aace3 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -34,6 +34,7 @@ import homeassistant.helpers.config_validation as cv 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_modbus_setup( 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_modbus_setup( ] 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 @@ class ModbusHub: 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 @@ class ModbusHub: 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 f2ed504b41b..d7a6b4cca0f 100644 --- a/homeassistant/components/modbus/sensor.py +++ b/homeassistant/components/modbus/sensor.py @@ -28,7 +28,7 @@ from homeassistant.helpers.update_coordinator import ( 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__) @@ -50,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)) diff --git a/homeassistant/components/modbus/strings.json b/homeassistant/components/modbus/strings.json index 61694074d79..5f45d0df596 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 f5f88ea5f59..ca08ace853a 100644 --- a/homeassistant/components/modbus/validators.py +++ b/homeassistant/components/modbus/validators.py @@ -25,11 +25,15 @@ from homeassistant.const import ( 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 @@ from .const import ( _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 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: - 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" - ) - raise vol.Invalid(error) 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: - 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" - ) - 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}" + config[CONF_COUNT] = DEFAULT_STRUCT_FORMAT[data_type].register_count + if slave_count: + structure = ( + f">{slave_count + 1}{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 9ea0f6ddbc9..45b1e42c8bb 100644 --- a/homeassistant/components/motion_blinds/__init__.py +++ b/homeassistant/components/motion_blinds/__init__.py @@ -2,20 +2,16 @@ import asyncio from datetime import timedelta import logging -from socket import timeout -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING -from motionblinds import DEVICE_TYPES_WIFI, AsyncMotionMulticast, ParseException +from motionblinds import AsyncMotionMulticast 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 ( - ATTR_AVAILABLE, CONF_INTERFACE, CONF_WAIT_FOR_PUSH, DEFAULT_INTERFACE, @@ -27,89 +23,15 @@ from .const import ( KEY_MULTICAST_LISTENER, KEY_SETUP_LOCK, KEY_UNSUB_STOP, - KEY_VERSION, - MANUFACTURER, PLATFORMS, UPDATE_INTERVAL, - UPDATE_INTERVAL_FAST, ) +from .coordinator import DataUpdateCoordinatorMotionBlinds from .gateway import ConnectMotionGateway _LOGGER = logging.getLogger(__name__) -class DataUpdateCoordinatorMotionBlinds(DataUpdateCoordinator): - """Class to manage fetching data from single endpoint.""" - - def __init__( - self, - hass: HomeAssistant, - logger: logging.Logger, - coordinator_info: dict[str, Any], - *, - name: str, - update_interval: timedelta, - ) -> None: - """Initialize global data updater.""" - super().__init__( - hass, - logger, - name=name, - update_interval=update_interval, - ) - - self.api_lock = coordinator_info[KEY_API_LOCK] - self._gateway = coordinator_info[KEY_GATEWAY] - self._wait_for_push = coordinator_info[CONF_WAIT_FOR_PUSH] - - def update_gateway(self): - """Fetch data from gateway.""" - try: - self._gateway.Update() - except (timeout, ParseException): - # let the error be logged and handled by the motionblinds library - return {ATTR_AVAILABLE: False} - - return {ATTR_AVAILABLE: True} - - def update_blind(self, blind): - """Fetch data from a blind.""" - try: - if self._wait_for_push: - blind.Update() - else: - blind.Update_trigger() - except (timeout, ParseException): - # let the error be logged and handled by the motionblinds library - return {ATTR_AVAILABLE: False} - - return {ATTR_AVAILABLE: True} - - async def _async_update_data(self): - """Fetch the latest data from the gateway and blinds.""" - data = {} - - async with self.api_lock: - data[KEY_GATEWAY] = await self.hass.async_add_executor_job( - self.update_gateway - ) - - for blind in self._gateway.device_list.values(): - await asyncio.sleep(1.5) - async with self.api_lock: - data[blind.mac] = await self.hass.async_add_executor_job( - self.update_blind, blind - ) - - all_available = all(device[ATTR_AVAILABLE] for device in data.values()) - if all_available: - self.update_interval = timedelta(seconds=UPDATE_INTERVAL) - else: - self.update_interval = timedelta(seconds=UPDATE_INTERVAL_FAST) - - return data - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up the motion_blinds components from a config entry.""" hass.data.setdefault(DOMAIN, {}) @@ -183,32 +105,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Fetch initial data so we have data when entities subscribe await coordinator.async_config_entry_first_refresh() - 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 d241f03a02e..429259a91c1 100644 --- a/homeassistant/components/motion_blinds/const.py +++ b/homeassistant/components/motion_blinds/const.py @@ -18,7 +18,6 @@ KEY_COORDINATOR = "coordinator" 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/coordinator.py b/homeassistant/components/motion_blinds/coordinator.py new file mode 100644 index 00000000000..cfc7d319b38 --- /dev/null +++ b/homeassistant/components/motion_blinds/coordinator.py @@ -0,0 +1,94 @@ +"""DataUpdateCoordinator for motion blinds integration.""" +import asyncio +from datetime import timedelta +import logging +from socket import timeout +from typing import Any + +from motionblinds import ParseException + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import ( + ATTR_AVAILABLE, + CONF_WAIT_FOR_PUSH, + KEY_API_LOCK, + KEY_GATEWAY, + UPDATE_INTERVAL, + UPDATE_INTERVAL_FAST, +) + +_LOGGER = logging.getLogger(__name__) + + +class DataUpdateCoordinatorMotionBlinds(DataUpdateCoordinator): + """Class to manage fetching data from single endpoint.""" + + def __init__( + self, + hass: HomeAssistant, + logger: logging.Logger, + coordinator_info: dict[str, Any], + *, + name: str, + update_interval: timedelta, + ) -> None: + """Initialize global data updater.""" + super().__init__( + hass, + logger, + name=name, + update_interval=update_interval, + ) + + self.api_lock = coordinator_info[KEY_API_LOCK] + self._gateway = coordinator_info[KEY_GATEWAY] + self._wait_for_push = coordinator_info[CONF_WAIT_FOR_PUSH] + + def update_gateway(self): + """Fetch data from gateway.""" + try: + self._gateway.Update() + except (timeout, ParseException): + # let the error be logged and handled by the motionblinds library + return {ATTR_AVAILABLE: False} + + return {ATTR_AVAILABLE: True} + + def update_blind(self, blind): + """Fetch data from a blind.""" + try: + if self._wait_for_push: + blind.Update() + else: + blind.Update_trigger() + except (timeout, ParseException): + # let the error be logged and handled by the motionblinds library + return {ATTR_AVAILABLE: False} + + return {ATTR_AVAILABLE: True} + + async def _async_update_data(self): + """Fetch the latest data from the gateway and blinds.""" + data = {} + + async with self.api_lock: + data[KEY_GATEWAY] = await self.hass.async_add_executor_job( + self.update_gateway + ) + + for blind in self._gateway.device_list.values(): + await asyncio.sleep(1.5) + async with self.api_lock: + data[blind.mac] = await self.hass.async_add_executor_job( + self.update_blind, blind + ) + + all_available = all(device[ATTR_AVAILABLE] for device in data.values()) + if all_available: + self.update_interval = timedelta(seconds=UPDATE_INTERVAL) + else: + self.update_interval = timedelta(seconds=UPDATE_INTERVAL_FAST) + + return data diff --git a/homeassistant/components/motion_blinds/cover.py b/homeassistant/components/motion_blinds/cover.py index c9578380048..833d2640202 100644 --- a/homeassistant/components/motion_blinds/cover.py +++ b/homeassistant/components/motion_blinds/cover.py @@ -16,15 +16,9 @@ from homeassistant.components.cover import ( ) 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 @@ from .const import ( 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 @@ class MotionPositionDevice(CoordinatorEntity, CoverEntity): 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 @@ class MotionTiltOnlyDevice(MotionTiltDevice): 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 00000000000..56eccb04eae --- /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 .const import ( + ATTR_AVAILABLE, + DEFAULT_GATEWAY_NAME, + DOMAIN, + KEY_GATEWAY, + MANUFACTURER, +) +from .coordinator import DataUpdateCoordinatorMotionBlinds +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 bca1c1ef1dd..d8dc25e0006 100644 --- a/homeassistant/components/motion_blinds/sensor.py +++ b/homeassistant/components/motion_blinds/sensor.py @@ -9,16 +9,12 @@ from homeassistant.const import ( 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 @@ class MotionBatterySensor(CoordinatorEntity, SensorEntity): """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 @@ class MotionTDBUBatterySensor(MotionBatterySensor): 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 @@ class MotionTDBUBatterySensor(MotionBatterySensor): 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 0e0a32bfb24..cb9468c3a27 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 683308e081c..fd3f0ec86c0 100644 --- a/homeassistant/components/motioneye/camera.py +++ b/homeassistant/components/motioneye/camera.py @@ -143,6 +143,10 @@ async def async_setup_entry( 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 @@ class MotionEyeMjpegCamera(MotionEyeEntity, MjpegCamera): 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 @@ class MotionEyeMjpegCamera(MotionEyeEntity, MjpegCamera): ) 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 9ec6447b32c..7caeb2b51f7 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -5,7 +5,7 @@ import asyncio 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 @@ -24,7 +24,7 @@ from homeassistant.const import ( SERVICE_RELOAD, ) from homeassistant.core import HassJob, HomeAssistant, ServiceCall, callback -from homeassistant.exceptions import TemplateError, Unauthorized +from homeassistant.exceptions import HomeAssistantError, TemplateError, Unauthorized from homeassistant.helpers import config_validation as cv, event as ev, template from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -248,7 +248,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 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_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) 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( @@ -363,8 +364,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def _reload_config(call: ServiceCall) -> None: """Reload the platforms.""" - # Fetch updated manual configured items and validate - config_yaml = await async_integration_yaml_config(hass, DOMAIN) or {} + # Fetch updated manually configured items and validate + if ( + config_yaml := await async_integration_yaml_config(hass, DOMAIN) + ) is None: + # Raise in case we have an invalid configuration + raise HomeAssistantError( + "Error reloading manually configured MQTT items, " + "check your configuration.yaml" + ) mqtt_data.config = config_yaml.get(DOMAIN, {}) # Reload the modern yaml platforms diff --git a/homeassistant/components/mqtt/alarm_control_panel.py b/homeassistant/components/mqtt/alarm_control_panel.py index a0939fdc615..3600d9663dd 100644 --- a/homeassistant/components/mqtt/alarm_control_panel.py +++ b/homeassistant/components/mqtt/alarm_control_panel.py @@ -42,9 +42,14 @@ from .const import ( CONF_SUPPORTED_FEATURES, ) from .debug_info import log_messages -from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper +from .mixins import ( + MQTT_ENTITY_COMMON_SCHEMA, + MqttEntity, + async_setup_entry_helper, + write_state_on_attr_change, +) from .models import MqttCommandTemplate, MqttValueTemplate, ReceiveMessage -from .util import get_mqtt_data, valid_publish_topic, valid_subscribe_topic +from .util import valid_publish_topic, valid_subscribe_topic _LOGGER = logging.getLogger(__name__) @@ -153,18 +158,6 @@ class MqttAlarm(MqttEntity, alarm.AlarmControlPanelEntity): _entity_id_format = alarm.ENTITY_ID_FORMAT _attributes_extra_blocked = MQTT_ALARM_ATTRIBUTES_BLOCKED - def __init__( - self, - hass: HomeAssistant, - config: ConfigType, - config_entry: ConfigEntry, - 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 def config_schema() -> vol.Schema: """Return the config schema.""" @@ -183,11 +176,22 @@ class MqttAlarm(MqttEntity, alarm.AlarmControlPanelEntity): 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.""" @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_state"}) def message_received(msg: ReceiveMessage) -> None: """Run when new MQTT message has been received.""" payload = self._value_template(msg.payload) @@ -205,8 +209,7 @@ class MqttAlarm(MqttEntity, alarm.AlarmControlPanelEntity): ): _LOGGER.warning("Received unexpected payload: %s", msg.payload) return - self._state = str(payload) - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) + self._attr_state = str(payload) self._sub_state = subscription.async_prepare_subscribe_topics( self.hass, @@ -225,26 +228,6 @@ class MqttAlarm(MqttEntity, alarm.AlarmControlPanelEntity): """(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 83bca91f4e1..c0f4cc7786e 100644 --- a/homeassistant/components/mqtt/binary_sensor.py +++ b/homeassistant/components/mqtt/binary_sensor.py @@ -29,7 +29,7 @@ from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback 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 @@ from .mixins import ( 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__) @@ -98,22 +98,11 @@ class MqttBinarySensor(MqttEntity, BinarySensorEntity, RestoreEntity): """Representation a binary sensor that is updated by MQTT.""" _default_name = DEFAULT_NAME + _delay_listener: CALLBACK_TYPE | None = None _entity_id_format = binary_sensor.ENTITY_ID_FORMAT _expired: bool | None _expire_after: int | None - - def __init__( - self, - hass: HomeAssistant, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None, - ) -> None: - """Initialize the MQTT binary sensor.""" - self._expiration_trigger: CALLBACK_TYPE | None = None - self._delay_listener: CALLBACK_TYPE | None = None - - MqttEntity.__init__(self, hass, config, config_entry, discovery_data) + _expiration_trigger: CALLBACK_TYPE | None = None async def mqtt_async_added_to_hass(self) -> None: """Restore state for entities with expire_after set.""" @@ -128,15 +117,17 @@ class MqttBinarySensor(MqttEntity, BinarySensorEntity, RestoreEntity): 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 +135,7 @@ class MqttBinarySensor(MqttEntity, BinarySensorEntity, RestoreEntity): " expiring %s" ), self.entity_id, - expiration_at - time_now, + remain_seconds, ) async def async_will_remove_from_hass(self) -> None: @@ -189,6 +180,7 @@ class MqttBinarySensor(MqttEntity, BinarySensorEntity, RestoreEntity): @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 +194,8 @@ class MqttBinarySensor(MqttEntity, BinarySensorEntity, RestoreEntity): 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) @@ -257,8 +247,6 @@ class MqttBinarySensor(MqttEntity, BinarySensorEntity, RestoreEntity): 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/button.py b/homeassistant/components/mqtt/button.py index 9b3b04a54f5..47ac12386f7 100644 --- a/homeassistant/components/mqtt/button.py +++ b/homeassistant/components/mqtt/button.py @@ -73,16 +73,6 @@ class MqttButton(MqttEntity, ButtonEntity): _default_name = DEFAULT_NAME _entity_id_format = button.ENTITY_ID_FORMAT - def __init__( - self, - hass: HomeAssistant, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None, - ) -> None: - """Initialize the MQTT button.""" - MqttEntity.__init__(self, hass, config, config_entry, discovery_data) - @staticmethod def config_schema() -> vol.Schema: """Return the config schema.""" diff --git a/homeassistant/components/mqtt/camera.py b/homeassistant/components/mqtt/camera.py index 166bfdd38cc..c8402e501b0 100644 --- a/homeassistant/components/mqtt/camera.py +++ b/homeassistant/components/mqtt/camera.py @@ -4,6 +4,7 @@ from __future__ import annotations from base64 import b64decode import functools import logging +from typing import TYPE_CHECKING import voluptuous as vol @@ -83,6 +84,7 @@ class MqttCamera(MqttEntity, Camera): _default_name = DEFAULT_NAME _entity_id_format: str = camera.ENTITY_ID_FORMAT _attributes_extra_blocked: frozenset[str] = MQTT_CAMERA_ATTRIBUTES_BLOCKED + _last_image: bytes | None = None def __init__( self, @@ -92,8 +94,6 @@ class MqttCamera(MqttEntity, Camera): discovery_data: DiscoveryInfoType | None, ) -> None: """Initialize the MQTT Camera.""" - self._last_image: bytes | None = None - Camera.__init__(self) MqttEntity.__init__(self, hass, config, config_entry, discovery_data) @@ -112,7 +112,8 @@ class MqttCamera(MqttEntity, Camera): 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 62f1f55401d..733645c4788 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 @@ class MQTT: ) -> 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 @@ class MQTT: 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/climate.py b/homeassistant/components/mqtt/climate.py index d5bda57c2b3..77f28e1b5ca 100644 --- a/homeassistant/components/mqtt/climate.py +++ b/homeassistant/components/mqtt/climate.py @@ -82,7 +82,12 @@ from .const import ( PAYLOAD_NONE, ) from .debug_info import log_messages -from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper +from .mixins import ( + MQTT_ENTITY_COMMON_SCHEMA, + MqttEntity, + async_setup_entry_helper, + write_state_on_attr_change, +) from .models import ( MqttCommandTemplate, MqttValueTemplate, @@ -90,7 +95,7 @@ from .models import ( ReceiveMessage, ReceivePayloadType, ) -from .util import get_mqtt_data, valid_publish_topic, valid_subscribe_topic +from .util import valid_publish_topic, valid_subscribe_topic _LOGGER = logging.getLogger(__name__) @@ -419,28 +424,16 @@ class MqttTemperatureControlEntity(MqttEntity, ABC): climate and water_heater platforms. """ - _attr_target_temperature_low: float | None - _attr_target_temperature_high: float | None + _attr_target_temperature_low: float | None = None + _attr_target_temperature_high: float | None = None + _feature_preset_mode: bool = False _optimistic: bool _topic: dict[str, Any] _command_templates: dict[str, Callable[[PublishPayloadType], PublishPayloadType]] _value_templates: dict[str, Callable[[ReceivePayloadType], ReceivePayloadType]] - def __init__( - self, - hass: HomeAssistant, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None, - ) -> None: - """Initialize the temperature controlled device.""" - self._attr_target_temperature_low = None - self._attr_target_temperature_high = None - self._feature_preset_mode = False - MqttEntity.__init__(self, hass, config, config_entry, discovery_data) - def add_subscription( self, topics: dict[str, dict[str, Any]], @@ -478,11 +471,9 @@ class MqttTemperatureControlEntity(MqttEntity, ABC): return if payload == PAYLOAD_NONE: setattr(self, attr, None) - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) return try: setattr(self, attr, float(payload)) - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) except ValueError: _LOGGER.error("Could not parse %s from %s", template_name, payload) @@ -493,6 +484,7 @@ class MqttTemperatureControlEntity(MqttEntity, ABC): @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_current_temperature"}) def handle_current_temperature_received(msg: ReceiveMessage) -> None: """Handle current temperature coming via MQTT.""" self.handle_climate_attribute_received( @@ -505,6 +497,7 @@ class MqttTemperatureControlEntity(MqttEntity, ABC): @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_target_temperature"}) def handle_target_temperature_received(msg: ReceiveMessage) -> None: """Handle target temperature coming via MQTT.""" self.handle_climate_attribute_received( @@ -517,6 +510,7 @@ class MqttTemperatureControlEntity(MqttEntity, ABC): @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_target_temperature_low"}) def handle_temperature_low_received(msg: ReceiveMessage) -> None: """Handle target temperature low coming via MQTT.""" self.handle_climate_attribute_received( @@ -529,6 +523,7 @@ class MqttTemperatureControlEntity(MqttEntity, ABC): @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_target_temperature_high"}) def handle_temperature_high_received(msg: ReceiveMessage) -> None: """Handle target temperature high coming via MQTT.""" self.handle_climate_attribute_received( @@ -612,27 +607,14 @@ class MqttTemperatureControlEntity(MqttEntity, ABC): class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): """Representation of an MQTT climate device.""" + _attr_fan_mode: str | None = None + _attr_hvac_mode: HVACMode | None = None + _attr_is_aux_heat: bool | None = None + _attr_swing_mode: str | None = None _default_name = DEFAULT_NAME _entity_id_format = climate.ENTITY_ID_FORMAT _attributes_extra_blocked = MQTT_CLIMATE_ATTRIBUTES_BLOCKED - def __init__( - self, - hass: HomeAssistant, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None, - ) -> None: - """Initialize the climate device.""" - self._attr_fan_mode = None - self._attr_hvac_action = None - self._attr_hvac_mode = None - self._attr_is_aux_heat = None - self._attr_swing_mode = None - MqttTemperatureControlEntity.__init__( - self, hass, config, config_entry, discovery_data - ) - @staticmethod def config_schema() -> vol.Schema: """Return the config schema.""" @@ -789,6 +771,7 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_hvac_action"}) def handle_action_received(msg: ReceiveMessage) -> None: """Handle receiving action via MQTT.""" payload = self.render_template(msg, CONF_ACTION_TEMPLATE) @@ -808,12 +791,12 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): payload, ) return - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) self.add_subscription(topics, CONF_ACTION_TOPIC, handle_action_received) @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_current_humidity"}) def handle_current_humidity_received(msg: ReceiveMessage) -> None: """Handle current humidity coming via MQTT.""" self.handle_climate_attribute_received( @@ -825,6 +808,7 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): ) @callback + @write_state_on_attr_change(self, {"_attr_target_humidity"}) @log_messages(self.hass, self.entity_id) def handle_target_humidity_received(msg: ReceiveMessage) -> None: """Handle target humidity coming via MQTT.""" @@ -848,10 +832,10 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): _LOGGER.error("Invalid %s mode: %s", mode_list, payload) else: setattr(self, attr, payload) - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_hvac_mode"}) def handle_current_mode_received(msg: ReceiveMessage) -> None: """Handle receiving mode via MQTT.""" handle_mode_received( @@ -864,6 +848,7 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_fan_mode"}) def handle_fan_mode_received(msg: ReceiveMessage) -> None: """Handle receiving fan mode via MQTT.""" handle_mode_received( @@ -879,6 +864,7 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_swing_mode"}) def handle_swing_mode_received(msg: ReceiveMessage) -> None: """Handle receiving swing mode via MQTT.""" handle_mode_received( @@ -913,13 +899,12 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): else: _LOGGER.error("Invalid %s mode: %s", attr, payload) - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) - # Options CONF_AUX_COMMAND_TOPIC, CONF_AUX_STATE_TOPIC # and CONF_AUX_STATE_TEMPLATE were deprecated in HA Core 2023.9 # Support will be removed in HA Core 2024.3 @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_is_aux_heat"}) def handle_aux_mode_received(msg: ReceiveMessage) -> None: """Handle receiving aux mode via MQTT.""" handle_onoff_mode_received( @@ -930,12 +915,12 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_preset_mode"}) def handle_preset_mode_received(msg: ReceiveMessage) -> None: """Handle receiving preset mode via MQTT.""" preset_mode = self.render_template(msg, CONF_PRESET_MODE_VALUE_TEMPLATE) if preset_mode in [PRESET_NONE, PAYLOAD_NONE]: self._attr_preset_mode = PRESET_NONE - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) return if not preset_mode: _LOGGER.debug("Ignoring empty preset_mode from '%s'", msg.topic) @@ -953,8 +938,6 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): else: self._attr_preset_mode = str(preset_mode) - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) - self.add_subscription( topics, CONF_PRESET_MODE_STATE_TOPIC, handle_preset_mode_received ) diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index 9f960b0d909..4f46dffec11 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -6,7 +6,7 @@ from collections.abc import Callable 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 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): ) -> 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 @@ class MQTTOptionsFlowHandler(OptionsFlow): 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 c11cf2dfb85..39c4090109c 100644 --- a/homeassistant/components/mqtt/cover.py +++ b/homeassistant/components/mqtt/cover.py @@ -13,7 +13,6 @@ from homeassistant.components.cover import ( ATTR_POSITION, ATTR_TILT_POSITION, DEVICE_CLASSES_SCHEMA, - CoverDeviceClass, CoverEntity, CoverEntityFeature, ) @@ -46,9 +45,14 @@ from .const import ( DEFAULT_OPTIMISTIC, ) from .debug_info import log_messages -from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper +from .mixins import ( + MQTT_ENTITY_COMMON_SCHEMA, + MqttEntity, + async_setup_entry_helper, + write_state_on_attr_change, +) from .models import MqttCommandTemplate, MqttValueTemplate, ReceiveMessage -from .util import get_mqtt_data, valid_publish_topic, valid_subscribe_topic +from .util import valid_publish_topic, valid_subscribe_topic _LOGGER = logging.getLogger(__name__) @@ -236,26 +240,12 @@ async def _async_setup_entity( class MqttCover(MqttEntity, CoverEntity): """Representation of a cover that can be controlled using MQTT.""" + _attr_is_closed: bool | None = None + _attributes_extra_blocked: frozenset[str] = MQTT_COVER_ATTRIBUTES_BLOCKED _default_name = DEFAULT_NAME _entity_id_format: str = cover.ENTITY_ID_FORMAT - _attributes_extra_blocked: frozenset[str] = MQTT_COVER_ATTRIBUTES_BLOCKED - - def __init__( - self, - hass: HomeAssistant, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None, - ) -> None: - """Initialize the cover.""" - self._position: int | None = None - self._state: str | None = None - - self._optimistic: bool | None = None - self._tilt_value: int | None = None - self._tilt_optimistic: bool | None = None - - MqttEntity.__init__(self, hass, config, config_entry, discovery_data) + _optimistic: bool + _tilt_optimistic: bool @staticmethod def config_schema() -> vol.Schema: @@ -288,20 +278,17 @@ class MqttCover(MqttEntity, CoverEntity): and config.get(CONF_TILT_STATUS_TOPIC) is None ) - if config[CONF_OPTIMISTIC] or ( + self._optimistic = config[CONF_OPTIMISTIC] or ( (no_position or optimistic_position) and (no_state or optimistic_state) and (no_tilt or optimistic_tilt) - ): - # Force into optimistic mode. - self._optimistic = True + ) + self._attr_assumed_state = self._optimistic - if ( + self._tilt_optimistic = ( config[CONF_TILT_STATE_OPTIMISTIC] or config.get(CONF_TILT_STATUS_TOPIC) is None - ): - # Force into optimistic tilt mode. - self._tilt_optimistic = True + ) template_config_attributes = { "position_open": self._config[CONF_POSITION_OPEN], @@ -335,12 +322,39 @@ class MqttCover(MqttEntity, CoverEntity): 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 + + @callback + def _update_state(self, state: str) -> None: + """Update the cover state.""" + self._attr_is_closed = state == STATE_CLOSED + self._attr_is_opening = state == STATE_OPENING + self._attr_is_closing = state == STATE_CLOSING + def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" topics = {} @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_current_cover_tilt_position"}) def tilt_message_received(msg: ReceiveMessage) -> None: """Handle tilt updates.""" payload = self._tilt_status_template(msg.payload) @@ -353,6 +367,9 @@ class MqttCover(MqttEntity, CoverEntity): @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change( + self, {"_attr_is_closed", "_attr_is_closing", "_attr_is_opening"} + ) def state_message_received(msg: ReceiveMessage) -> None: """Handle new MQTT state messages.""" payload = self._value_template(msg.payload) @@ -361,25 +378,24 @@ class MqttCover(MqttEntity, CoverEntity): _LOGGER.debug("Ignoring empty state message from '%s'", msg.topic) return + state: str if payload == self._config[CONF_STATE_STOPPED]: if self._config.get(CONF_GET_POSITION_TOPIC) is not None: - self._state = ( + state = ( STATE_CLOSED - if self._position == DEFAULT_POSITION_CLOSED + if self._attr_current_cover_position == DEFAULT_POSITION_CLOSED else STATE_OPEN ) else: - self._state = ( - STATE_CLOSED if self._state == STATE_CLOSING else STATE_OPEN - ) + state = STATE_CLOSED if self.state == STATE_CLOSING else STATE_OPEN elif payload == self._config[CONF_STATE_OPENING]: - self._state = STATE_OPENING + state = STATE_OPENING elif payload == self._config[CONF_STATE_CLOSING]: - self._state = STATE_CLOSING + state = STATE_CLOSING elif payload == self._config[CONF_STATE_OPEN]: - self._state = STATE_OPEN + state = STATE_OPEN elif payload == self._config[CONF_STATE_CLOSED]: - self._state = STATE_CLOSED + state = STATE_CLOSED else: _LOGGER.warning( ( @@ -389,11 +405,20 @@ class MqttCover(MqttEntity, CoverEntity): payload, ) return - - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) + self._update_state(state) @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change( + self, + { + "_attr_current_cover_position", + "_attr_current_cover_tilt_position", + "_attr_is_closed", + "_attr_is_closing", + "_attr_is_opening", + }, + ) def position_message_received(msg: ReceiveMessage) -> None: """Handle new MQTT position messages.""" payload: ReceivePayloadType = self._get_position_template(msg.payload) @@ -428,16 +453,14 @@ class MqttCover(MqttEntity, CoverEntity): _LOGGER.warning("Payload '%s' is not numeric", payload) return - self._position = percentage_payload + self._attr_current_cover_position = percentage_payload if self._config.get(CONF_STATE_TOPIC) is None: - self._state = ( + self._update_state( STATE_CLOSED if percentage_payload == DEFAULT_POSITION_CLOSED else STATE_OPEN ) - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) - if self._config.get(CONF_GET_POSITION_TOPIC): topics["get_position_topic"] = { "topic": self._config.get(CONF_GET_POSITION_TOPIC), @@ -470,67 +493,6 @@ class MqttCover(MqttEntity, CoverEntity): """(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.""" - if self._state is None: - return None - - return self._state == STATE_CLOSED - - @property - def is_opening(self) -> bool: - """Return true if the cover is actively opening.""" - return self._state == STATE_OPENING - - @property - def is_closing(self) -> bool: - """Return true if the cover is actively closing.""" - return self._state == STATE_CLOSING - - @property - def current_cover_position(self) -> int | None: - """Return current position of cover. - - None is unknown, 0 is closed, 100 is fully open. - """ - return self._position - - @property - 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. @@ -545,9 +507,9 @@ class MqttCover(MqttEntity, CoverEntity): ) if self._optimistic: # Optimistically assume that cover has changed state. - self._state = STATE_OPEN + self._update_state(STATE_OPEN) if self._config.get(CONF_GET_POSITION_TOPIC): - self._position = self.find_percentage_in_range( + self._attr_current_cover_position = self.find_percentage_in_range( self._config[CONF_POSITION_OPEN], COVER_PAYLOAD ) self.async_write_ha_state() @@ -566,9 +528,9 @@ class MqttCover(MqttEntity, CoverEntity): ) if self._optimistic: # Optimistically assume that cover has changed state. - self._state = STATE_CLOSED + self._update_state(STATE_CLOSED) if self._config.get(CONF_GET_POSITION_TOPIC): - self._position = self.find_percentage_in_range( + self._attr_current_cover_position = self.find_percentage_in_range( self._config[CONF_POSITION_CLOSED], COVER_PAYLOAD ) self.async_write_ha_state() @@ -606,7 +568,7 @@ class MqttCover(MqttEntity, CoverEntity): self._config[CONF_ENCODING], ) if self._tilt_optimistic: - self._tilt_value = self.find_percentage_in_range( + self._attr_current_cover_tilt_position = self.find_percentage_in_range( float(self._config[CONF_TILT_OPEN_POSITION]) ) self.async_write_ha_state() @@ -633,7 +595,7 @@ class MqttCover(MqttEntity, CoverEntity): self._config[CONF_ENCODING], ) if self._tilt_optimistic: - self._tilt_value = self.find_percentage_in_range( + self._attr_current_cover_tilt_position = self.find_percentage_in_range( float(self._config[CONF_TILT_CLOSED_POSITION]) ) self.async_write_ha_state() @@ -664,7 +626,7 @@ class MqttCover(MqttEntity, CoverEntity): ) if self._tilt_optimistic: _LOGGER.debug("Set tilt value optimistic") - self._tilt_value = percentage_tilt + self._attr_current_cover_tilt_position = percentage_tilt self.async_write_ha_state() async def async_set_cover_position(self, **kwargs: Any) -> None: @@ -690,12 +652,12 @@ class MqttCover(MqttEntity, CoverEntity): self._config[CONF_ENCODING], ) if self._optimistic: - self._state = ( + self._update_state( STATE_CLOSED if percentage_position == self._config[CONF_POSITION_CLOSED] else STATE_OPEN ) - self._position = percentage_position + self._attr_current_cover_position = percentage_position self.async_write_ha_state() async def async_toggle_tilt(self, **kwargs: Any) -> None: @@ -707,7 +669,7 @@ class MqttCover(MqttEntity, CoverEntity): def is_tilt_closed(self) -> bool: """Return if the cover is tilted closed.""" - return self._tilt_value == self.find_percentage_in_range( + return self._attr_current_cover_tilt_position == self.find_percentage_in_range( float(self._config[CONF_TILT_CLOSED_POSITION]) ) @@ -773,8 +735,7 @@ class MqttCover(MqttEntity, CoverEntity): <= self._config[CONF_TILT_MIN] ): level = self.find_percentage_in_range(payload) - self._tilt_value = level - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) + self._attr_current_cover_tilt_position = level else: _LOGGER.warning( "Payload '%s' is out of range, must be between '%s' and '%s' inclusive", diff --git a/homeassistant/components/mqtt/debug_info.py b/homeassistant/components/mqtt/debug_info.py index bdbdd74de96..6b4b90586a7 100644 --- a/homeassistant/components/mqtt/debug_info.py +++ b/homeassistant/components/mqtt/debug_info.py @@ -5,7 +5,7 @@ from collections import deque 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_tracker.py b/homeassistant/components/mqtt/device_tracker.py index 67355d9bca5..2270f2b4031 100644 --- a/homeassistant/components/mqtt/device_tracker.py +++ b/homeassistant/components/mqtt/device_tracker.py @@ -36,9 +36,10 @@ from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper, + write_state_on_attr_change, ) from .models import MqttValueTemplate, ReceiveMessage, ReceivePayloadType -from .util import get_mqtt_data, valid_subscribe_topic +from .util import valid_subscribe_topic CONF_PAYLOAD_HOME = "payload_home" CONF_PAYLOAD_NOT_HOME = "payload_not_home" @@ -106,19 +107,9 @@ class MqttDeviceTracker(MqttEntity, TrackerEntity): _default_name = None _entity_id_format = device_tracker.ENTITY_ID_FORMAT + _location_name: str | None = None _value_template: Callable[..., ReceivePayloadType] - def __init__( - self, - hass: HomeAssistant, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None, - ) -> None: - """Initialize the tracker.""" - self._location_name: str | None = None - MqttEntity.__init__(self, hass, config, config_entry, discovery_data) - @staticmethod def config_schema() -> vol.Schema: """Return the config schema.""" @@ -135,6 +126,7 @@ class MqttDeviceTracker(MqttEntity, TrackerEntity): @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_location_name"}) def message_received(msg: ReceiveMessage) -> None: """Handle new MQTT messages.""" payload: ReceivePayloadType = self._value_template(msg.payload) @@ -148,8 +140,6 @@ class MqttDeviceTracker(MqttEntity, TrackerEntity): assert isinstance(msg.payload, str) self._location_name = msg.payload - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) - state_topic: str | None = self._config.get(CONF_STATE_TOPIC) if state_topic is None: return diff --git a/homeassistant/components/mqtt/device_trigger.py b/homeassistant/components/mqtt/device_trigger.py index 36291ae0be8..fc7528743fa 100644 --- a/homeassistant/components/mqtt/device_trigger.py +++ b/homeassistant/components/mqtt/device_trigger.py @@ -3,7 +3,7 @@ from __future__ import annotations 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 173c583ca6a..82bae04d2c9 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 37885b628d2..c78319bb46a 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -7,7 +7,7 @@ import functools import logging import re import time -from typing import Any +from typing import TYPE_CHECKING, Any import voluptuous as vol @@ -48,7 +48,7 @@ TOPIC_MATCHER = re.compile( r"?(?P[a-zA-Z0-9_-]+)/config" ) -SUPPORTED_COMPONENTS = [ +SUPPORTED_COMPONENTS = { "alarm_control_panel", "binary_sensor", "button", @@ -75,7 +75,7 @@ SUPPORTED_COMPONENTS = [ "update", "vacuum", "water_heater", -] +} MQTT_DISCOVERY_UPDATED = "mqtt_discovery_updated_{}" MQTT_DISCOVERY_NEW = "mqtt_discovery_new_{}_{}" @@ -275,32 +275,34 @@ async def async_start( # noqa: C901 _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_start( # noqa: C901 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/event.py b/homeassistant/components/mqtt/event.py index 6f8be33f21a..c345655eea5 100644 --- a/homeassistant/components/mqtt/event.py +++ b/homeassistant/components/mqtt/event.py @@ -32,14 +32,18 @@ from .const import ( PAYLOAD_NONE, ) from .debug_info import log_messages -from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper +from .mixins import ( + MQTT_ENTITY_COMMON_SCHEMA, + MqttEntity, + async_setup_entry_helper, + write_state_on_attr_change, +) from .models import ( MqttValueTemplate, PayloadSentinel, ReceiveMessage, ReceivePayloadType, ) -from .util import get_mqtt_data _LOGGER = logging.getLogger(__name__) @@ -104,16 +108,6 @@ class MqttEvent(MqttEntity, EventEntity): _attributes_extra_blocked = MQTT_EVENT_ATTRIBUTES_BLOCKED _template: Callable[[ReceivePayloadType, PayloadSentinel], ReceivePayloadType] - def __init__( - self, - hass: HomeAssistant, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None, - ) -> None: - """Initialize the sensor.""" - MqttEntity.__init__(self, hass, config, config_entry, discovery_data) - @staticmethod def config_schema() -> vol.Schema: """Return the config schema.""" @@ -133,6 +127,7 @@ class MqttEvent(MqttEntity, EventEntity): @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"state"}) def message_received(msg: ReceiveMessage) -> None: """Handle new MQTT messages.""" event_attributes: dict[str, Any] = {} @@ -195,7 +190,6 @@ class MqttEvent(MqttEntity, EventEntity): payload, ) return - 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/fan.py b/homeassistant/components/mqtt/fan.py index 58189c3cb3e..0aad3a6afc0 100644 --- a/homeassistant/components/mqtt/fan.py +++ b/homeassistant/components/mqtt/fan.py @@ -50,7 +50,12 @@ from .const import ( PAYLOAD_NONE, ) from .debug_info import log_messages -from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper +from .mixins import ( + MQTT_ENTITY_COMMON_SCHEMA, + MqttEntity, + async_setup_entry_helper, + write_state_on_attr_change, +) from .models import ( MessageCallbackType, MqttCommandTemplate, @@ -59,7 +64,7 @@ from .models import ( ReceiveMessage, ReceivePayloadType, ) -from .util import get_mqtt_data, valid_publish_topic, valid_subscribe_topic +from .util import valid_publish_topic, valid_subscribe_topic CONF_DIRECTION_STATE_TOPIC = "direction_state_topic" CONF_DIRECTION_COMMAND_TOPIC = "direction_command_topic" @@ -215,6 +220,9 @@ async def _async_setup_entity( class MqttFan(MqttEntity, FanEntity): """A MQTT fan component.""" + _attr_percentage: int | None = None + _attr_preset_mode: str | None = None + _default_name = DEFAULT_NAME _entity_id_format = fan.ENTITY_ID_FORMAT _attributes_extra_blocked = MQTT_FAN_ATTRIBUTES_BLOCKED @@ -232,19 +240,6 @@ class MqttFan(MqttEntity, FanEntity): _payload: dict[str, Any] _speed_range: tuple[int, int] - def __init__( - self, - hass: HomeAssistant, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None, - ) -> None: - """Initialize the MQTT fan.""" - self._attr_percentage = None - self._attr_preset_mode = None - - MqttEntity.__init__(self, hass, config, config_entry, discovery_data) - @staticmethod def config_schema() -> vol.Schema: """Return the config schema.""" @@ -295,6 +290,7 @@ class MqttFan(MqttEntity, FanEntity): 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 ) @@ -366,6 +362,7 @@ class MqttFan(MqttEntity, FanEntity): @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_is_on"}) def state_received(msg: ReceiveMessage) -> None: """Handle new received MQTT message.""" payload = self._value_templates[CONF_STATE](msg.payload) @@ -378,12 +375,12 @@ class MqttFan(MqttEntity, FanEntity): self._attr_is_on = False elif payload == PAYLOAD_NONE: self._attr_is_on = None - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) add_subscribe_topic(CONF_STATE_TOPIC, state_received) @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_percentage"}) def percentage_received(msg: ReceiveMessage) -> None: """Handle new received MQTT message for the percentage.""" rendered_percentage_payload = self._value_templates[ATTR_PERCENTAGE]( @@ -394,7 +391,6 @@ class MqttFan(MqttEntity, FanEntity): return if rendered_percentage_payload == self._payload["PERCENTAGE_RESET"]: self._attr_percentage = None - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) return try: percentage = ranged_value_to_percentage( @@ -423,18 +419,17 @@ class MqttFan(MqttEntity, FanEntity): ) return self._attr_percentage = percentage - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) add_subscribe_topic(CONF_PERCENTAGE_STATE_TOPIC, percentage_received) @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_preset_mode"}) def preset_mode_received(msg: ReceiveMessage) -> None: """Handle new received MQTT message for preset mode.""" preset_mode = str(self._value_templates[ATTR_PRESET_MODE](msg.payload)) if preset_mode == self._payload["PRESET_MODE_RESET"]: self._attr_preset_mode = None - self.async_write_ha_state() return if not preset_mode: _LOGGER.debug("Ignoring empty preset_mode from '%s'", msg.topic) @@ -449,12 +444,12 @@ class MqttFan(MqttEntity, FanEntity): return self._attr_preset_mode = preset_mode - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) add_subscribe_topic(CONF_PRESET_MODE_STATE_TOPIC, preset_mode_received) @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_oscillating"}) def oscillation_received(msg: ReceiveMessage) -> None: """Handle new received MQTT message for the oscillation.""" payload = self._value_templates[ATTR_OSCILLATING](msg.payload) @@ -465,13 +460,13 @@ class MqttFan(MqttEntity, FanEntity): self._attr_oscillating = True elif payload == self._payload["OSCILLATE_OFF_PAYLOAD"]: self._attr_oscillating = False - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) if add_subscribe_topic(CONF_OSCILLATION_STATE_TOPIC, oscillation_received): self._attr_oscillating = False @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_current_direction"}) def direction_received(msg: ReceiveMessage) -> None: """Handle new received MQTT message for the direction.""" direction = self._value_templates[ATTR_DIRECTION](msg.payload) @@ -479,7 +474,6 @@ class MqttFan(MqttEntity, FanEntity): _LOGGER.debug("Ignoring empty direction from '%s'", msg.topic) return self._attr_current_direction = str(direction) - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) add_subscribe_topic(CONF_DIRECTION_STATE_TOPIC, direction_received) @@ -491,11 +485,6 @@ class MqttFan(MqttEntity, FanEntity): """(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 aebb05c19f7..05929ee904a 100644 --- a/homeassistant/components/mqtt/humidifier.py +++ b/homeassistant/components/mqtt/humidifier.py @@ -52,7 +52,12 @@ from .const import ( PAYLOAD_NONE, ) from .debug_info import log_messages -from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper +from .mixins import ( + MQTT_ENTITY_COMMON_SCHEMA, + MqttEntity, + async_setup_entry_helper, + write_state_on_attr_change, +) from .models import ( MqttCommandTemplate, MqttValueTemplate, @@ -60,7 +65,7 @@ from .models import ( ReceiveMessage, ReceivePayloadType, ) -from .util import get_mqtt_data, valid_publish_topic, valid_subscribe_topic +from .util import valid_publish_topic, valid_subscribe_topic CONF_AVAILABLE_MODES_LIST = "modes" CONF_DEVICE_CLASS = "device_class" @@ -207,6 +212,7 @@ async def _async_setup_entity( class MqttHumidifier(MqttEntity, HumidifierEntity): """A MQTT humidifier component.""" + _attr_mode: str | None = None _default_name = DEFAULT_NAME _entity_id_format = humidifier.ENTITY_ID_FORMAT _attributes_extra_blocked = MQTT_HUMIDIFIER_ATTRIBUTES_BLOCKED @@ -219,18 +225,6 @@ class MqttHumidifier(MqttEntity, HumidifierEntity): _payload: dict[str, str] _topic: dict[str, Any] - def __init__( - self, - hass: HomeAssistant, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None, - ) -> None: - """Initialize the MQTT humidifier.""" - self._attr_mode = None - - MqttEntity.__init__(self, hass, config, config_entry, discovery_data) - @staticmethod def config_schema() -> vol.Schema: """Return the config schema.""" @@ -260,6 +254,7 @@ class MqttHumidifier(MqttEntity, HumidifierEntity): 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 ) @@ -312,6 +307,7 @@ class MqttHumidifier(MqttEntity, HumidifierEntity): @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_is_on"}) def state_received(msg: ReceiveMessage) -> None: """Handle new received MQTT message.""" payload = self._value_templates[CONF_STATE](msg.payload) @@ -324,12 +320,12 @@ class MqttHumidifier(MqttEntity, HumidifierEntity): self._attr_is_on = False elif payload == PAYLOAD_NONE: self._attr_is_on = None - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) self.add_subscription(topics, CONF_STATE_TOPIC, state_received) @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_action"}) def action_received(msg: ReceiveMessage) -> None: """Handle new received MQTT message.""" action_payload = self._value_templates[ATTR_ACTION](msg.payload) @@ -346,12 +342,12 @@ class MqttHumidifier(MqttEntity, HumidifierEntity): action_payload, ) return - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) self.add_subscription(topics, CONF_ACTION_TOPIC, action_received) @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_current_humidity"}) def current_humidity_received(msg: ReceiveMessage) -> None: """Handle new received MQTT message for the current humidity.""" rendered_current_humidity_payload = self._value_templates[ @@ -359,7 +355,6 @@ class MqttHumidifier(MqttEntity, HumidifierEntity): ](msg.payload) if rendered_current_humidity_payload == self._payload["HUMIDITY_RESET"]: self._attr_current_humidity = None - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) return if not rendered_current_humidity_payload: _LOGGER.debug("Ignoring empty current humidity from '%s'", msg.topic) @@ -383,7 +378,6 @@ class MqttHumidifier(MqttEntity, HumidifierEntity): ) return self._attr_current_humidity = current_humidity - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) self.add_subscription( topics, CONF_CURRENT_HUMIDITY_TOPIC, current_humidity_received @@ -391,6 +385,7 @@ class MqttHumidifier(MqttEntity, HumidifierEntity): @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_target_humidity"}) def target_humidity_received(msg: ReceiveMessage) -> None: """Handle new received MQTT message for the target humidity.""" rendered_target_humidity_payload = self._value_templates[ATTR_HUMIDITY]( @@ -401,7 +396,6 @@ class MqttHumidifier(MqttEntity, HumidifierEntity): return if rendered_target_humidity_payload == self._payload["HUMIDITY_RESET"]: self._attr_target_humidity = None - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) return try: target_humidity = round(float(rendered_target_humidity_payload)) @@ -425,7 +419,6 @@ class MqttHumidifier(MqttEntity, HumidifierEntity): ) return self._attr_target_humidity = target_humidity - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) self.add_subscription( topics, CONF_TARGET_HUMIDITY_STATE_TOPIC, target_humidity_received @@ -433,12 +426,12 @@ class MqttHumidifier(MqttEntity, HumidifierEntity): @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_mode"}) def mode_received(msg: ReceiveMessage) -> None: """Handle new received MQTT message for mode.""" mode = str(self._value_templates[ATTR_MODE](msg.payload)) if mode == self._payload["MODE_RESET"]: self._attr_mode = None - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) return if not mode: _LOGGER.debug("Ignoring empty mode from '%s'", msg.topic) @@ -453,7 +446,6 @@ class MqttHumidifier(MqttEntity, HumidifierEntity): return self._attr_mode = mode - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) self.add_subscription(topics, CONF_MODE_STATE_TOPIC, mode_received) @@ -465,11 +457,6 @@ class MqttHumidifier(MqttEntity, HumidifierEntity): """(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 da62416d29e..da526575a77 100644 --- a/homeassistant/components/mqtt/image.py +++ b/homeassistant/components/mqtt/image.py @@ -6,7 +6,7 @@ import binascii 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 @@ class MqttImage(MqttEntity, ImageEntity): 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 44db3581f8b..68c7eda16ea 100644 --- a/homeassistant/components/mqtt/lawn_mower.py +++ b/homeassistant/components/mqtt/lawn_mower.py @@ -32,7 +32,12 @@ from .const import ( DEFAULT_RETAIN, ) from .debug_info import log_messages -from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper +from .mixins import ( + MQTT_ENTITY_COMMON_SCHEMA, + MqttEntity, + async_setup_entry_helper, + write_state_on_attr_change, +) from .models import ( MqttCommandTemplate, MqttValueTemplate, @@ -40,7 +45,7 @@ from .models import ( ReceiveMessage, ReceivePayloadType, ) -from .util import get_mqtt_data, valid_publish_topic, valid_subscribe_topic +from .util import valid_publish_topic, valid_subscribe_topic _LOGGER = logging.getLogger(__name__) @@ -113,19 +118,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, - hass: HomeAssistant, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None, - ) -> None: - """Initialize the MQTT lawn mower.""" - self._attr_current_option = None - LawnMowerEntity.__init__(self) - MqttEntity.__init__(self, hass, config, config_entry, discovery_data) @staticmethod def config_schema() -> vol.Schema: @@ -134,7 +126,7 @@ class MqttLawnMower(MqttEntity, LawnMowerEntity, RestoreEntity): 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 @@ -169,6 +161,7 @@ class MqttLawnMower(MqttEntity, LawnMowerEntity, RestoreEntity): @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_activity"}) def message_received(msg: ReceiveMessage) -> None: """Handle new MQTT messages.""" payload = str(self._value_template(msg.payload)) @@ -181,7 +174,6 @@ class MqttLawnMower(MqttEntity, LawnMowerEntity, RestoreEntity): return if payload.lower() == "none": self._attr_activity = None - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) return try: @@ -194,11 +186,10 @@ class MqttLawnMower(MqttEntity, LawnMowerEntity, RestoreEntity): [option.value for option in LawnMowerActivity], ) return - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) 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 +208,16 @@ class MqttLawnMower(MqttEntity, LawnMowerEntity, RestoreEntity): """(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 9a1600f5865..65c05501658 100644 --- a/homeassistant/components/mqtt/light/schema_basic.py +++ b/homeassistant/components/mqtt/light/schema_basic.py @@ -55,7 +55,7 @@ from ..const import ( PAYLOAD_NONE, ) from ..debug_info import log_messages -from ..mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity +from ..mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, write_state_on_attr_change from ..models import ( MessageCallbackType, MqttCommandTemplate, @@ -66,7 +66,7 @@ from ..models import ( ReceivePayloadType, TemplateVarsType, ) -from ..util import get_mqtt_data, valid_publish_topic, valid_subscribe_topic +from ..util import valid_publish_topic, valid_subscribe_topic from .schema import MQTT_LIGHT_SCHEMA_SCHEMA _LOGGER = logging.getLogger(__name__) @@ -264,16 +264,6 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): _optimistic_rgbww_color: bool _optimistic_xy_color: bool - def __init__( - self, - hass: HomeAssistant, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None, - ) -> None: - """Initialize MQTT light.""" - MqttEntity.__init__(self, hass, config, config_entry, discovery_data) - @staticmethod def config_schema() -> vol.Schema: """Return the config schema.""" @@ -330,6 +320,7 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): 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 = ( @@ -414,6 +405,7 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_is_on"}) def state_received(msg: ReceiveMessage) -> None: """Handle new MQTT messages.""" payload = self._value_templates[CONF_STATE_VALUE_TEMPLATE]( @@ -429,7 +421,6 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): self._attr_is_on = False elif payload == PAYLOAD_NONE: self._attr_is_on = None - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) if self._topic[CONF_STATE_TOPIC] is not None: topics[CONF_STATE_TOPIC] = { @@ -441,6 +432,7 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_brightness"}) def brightness_received(msg: ReceiveMessage) -> None: """Handle new MQTT messages for the brightness.""" payload = self._value_templates[CONF_BRIGHTNESS_VALUE_TEMPLATE]( @@ -458,8 +450,6 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): percent_bright = device_value / self._config[CONF_BRIGHTNESS_SCALE] self._attr_brightness = min(round(percent_bright * 255), 255) - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) - add_topic(CONF_BRIGHTNESS_STATE_TOPIC, brightness_received) @callback @@ -500,6 +490,9 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change( + self, {"_attr_brightness", "_attr_color_mode", "_attr_rgb_color"} + ) def rgb_received(msg: ReceiveMessage) -> None: """Handle new MQTT messages for RGB.""" rgb = _rgbx_received( @@ -508,12 +501,14 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): if rgb is None: return self._attr_rgb_color = cast(tuple[int, int, int], rgb) - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) add_topic(CONF_RGB_STATE_TOPIC, rgb_received) @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change( + self, {"_attr_brightness", "_attr_color_mode", "_attr_rgbw_color"} + ) def rgbw_received(msg: ReceiveMessage) -> None: """Handle new MQTT messages for RGBW.""" rgbw = _rgbx_received( @@ -525,12 +520,14 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): if rgbw is None: return self._attr_rgbw_color = cast(tuple[int, int, int, int], rgbw) - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) add_topic(CONF_RGBW_STATE_TOPIC, rgbw_received) @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change( + self, {"_attr_brightness", "_attr_color_mode", "_attr_rgbww_color"} + ) def rgbww_received(msg: ReceiveMessage) -> None: """Handle new MQTT messages for RGBWW.""" @@ -557,12 +554,12 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): if rgbww is None: return self._attr_rgbww_color = cast(tuple[int, int, int, int, int], rgbww) - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) add_topic(CONF_RGBWW_STATE_TOPIC, rgbww_received) @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_color_mode"}) def color_mode_received(msg: ReceiveMessage) -> None: """Handle new MQTT messages for color mode.""" payload = self._value_templates[CONF_COLOR_MODE_VALUE_TEMPLATE]( @@ -572,13 +569,13 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): _LOGGER.debug("Ignoring empty color mode message from '%s'", msg.topic) return - self._attr_color_mode = str(payload) - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) + self._attr_color_mode = ColorMode(str(payload)) add_topic(CONF_COLOR_MODE_STATE_TOPIC, color_mode_received) @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_color_mode", "_attr_color_temp"}) def color_temp_received(msg: ReceiveMessage) -> None: """Handle new MQTT messages for color temperature.""" payload = self._value_templates[CONF_COLOR_TEMP_VALUE_TEMPLATE]( @@ -591,12 +588,12 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): if self._optimistic_color_mode: self._attr_color_mode = ColorMode.COLOR_TEMP self._attr_color_temp = int(payload) - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) add_topic(CONF_COLOR_TEMP_STATE_TOPIC, color_temp_received) @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_effect"}) def effect_received(msg: ReceiveMessage) -> None: """Handle new MQTT messages for effect.""" payload = self._value_templates[CONF_EFFECT_VALUE_TEMPLATE]( @@ -607,12 +604,12 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): return self._attr_effect = str(payload) - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) add_topic(CONF_EFFECT_STATE_TOPIC, effect_received) @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_color_mode", "_attr_hs_color"}) def hs_received(msg: ReceiveMessage) -> None: """Handle new MQTT messages for hs color.""" payload = self._value_templates[CONF_HS_VALUE_TEMPLATE]( @@ -626,7 +623,6 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): if self._optimistic_color_mode: self._attr_color_mode = ColorMode.HS self._attr_hs_color = cast(tuple[float, float], hs_color) - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) except ValueError: _LOGGER.warning("Failed to parse hs state update: '%s'", payload) @@ -634,6 +630,7 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_color_mode", "_attr_xy_color"}) def xy_received(msg: ReceiveMessage) -> None: """Handle new MQTT messages for xy color.""" payload = self._value_templates[CONF_XY_VALUE_TEMPLATE]( @@ -647,7 +644,6 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): if self._optimistic_color_mode: self._attr_color_mode = ColorMode.XY self._attr_xy_color = cast(tuple[float, float], xy_color) - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) add_topic(CONF_XY_STATE_TOPIC, xy_received) @@ -684,11 +680,6 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): 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 b7787912161..462280b1516 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -63,9 +63,9 @@ from ..const import ( CONF_STATE_TOPIC, ) from ..debug_info import log_messages -from ..mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity +from ..mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, write_state_on_attr_change from ..models import ReceiveMessage -from ..util import get_mqtt_data, valid_subscribe_topic +from ..util import valid_subscribe_topic from .schema import MQTT_LIGHT_SCHEMA_SCHEMA from .schema_basic import ( CONF_BRIGHTNESS_SCALE, @@ -184,21 +184,11 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): _entity_id_format = ENTITY_ID_FORMAT _attributes_extra_blocked = MQTT_LIGHT_ATTRIBUTES_BLOCKED + _fixed_color_mode: ColorMode | str | None = None _flash_times: dict[str, int | None] _topic: dict[str, str | None] _optimistic: bool - def __init__( - self, - hass: HomeAssistant, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None, - ) -> None: - """Initialize MQTT JSON light.""" - self._fixed_color_mode: ColorMode | str | None = None - MqttEntity.__init__(self, hass, config, config_entry, discovery_data) - @staticmethod def config_schema() -> vol.Schema: """Return the config schema.""" @@ -215,6 +205,7 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): } 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) @@ -346,6 +337,21 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change( + self, + { + "_attr_brightness", + "_attr_color_temp", + "_attr_effect", + "_attr_hs_color", + "_attr_is_on", + "_attr_rgb_color", + "_attr_rgbw_color", + "_attr_rgbww_color", + "_attr_xy_color", + "color_mode", + }, + ) def state_received(msg: ReceiveMessage) -> None: """Handle new MQTT messages.""" values = json_loads_object(msg.payload) @@ -418,8 +424,6 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): with suppress(KeyError): self._attr_effect = cast(str, values["effect"]) - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) - if self._topic[CONF_STATE_TOPIC] is not None: self._sub_state = subscription.async_prepare_subscribe_topics( self.hass, @@ -462,11 +466,6 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): ) 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 98ee7648eeb..a225ce43efa 100644 --- a/homeassistant/components/mqtt/light/schema_template.py +++ b/homeassistant/components/mqtt/light/schema_template.py @@ -46,7 +46,7 @@ from ..const import ( PAYLOAD_NONE, ) from ..debug_info import log_messages -from ..mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity +from ..mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, write_state_on_attr_change from ..models import ( MqttCommandTemplate, MqttValueTemplate, @@ -54,7 +54,6 @@ from ..models import ( ReceiveMessage, ReceivePayloadType, ) -from ..util import get_mqtt_data from .schema import MQTT_LIGHT_SCHEMA_SCHEMA from .schema_basic import MQTT_LIGHT_ATTRIBUTES_BLOCKED @@ -139,16 +138,6 @@ class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity): _fixed_color_mode: ColorMode | str | None _topics: dict[str, str | None] - def __init__( - self, - hass: HomeAssistant, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None, - ) -> None: - """Initialize a MQTT Template light.""" - MqttEntity.__init__(self, hass, config, config_entry, discovery_data) - @staticmethod def config_schema() -> vol.Schema: """Return the config schema.""" @@ -179,6 +168,7 @@ class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity): 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: @@ -214,6 +204,17 @@ class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity): @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change( + self, + { + "_attr_brightness", + "_attr_color_mode", + "_attr_color_temp", + "_attr_effect", + "_attr_hs_color", + "_attr_is_on", + }, + ) def state_received(msg: ReceiveMessage) -> None: """Handle new MQTT messages.""" state = self._value_templates[CONF_STATE_TEMPLATE](msg.payload) @@ -282,8 +283,6 @@ class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity): else: _LOGGER.warning("Unsupported effect value received") - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) - if self._topics[CONF_STATE_TOPIC] is not None: self._sub_state = subscription.async_prepare_subscribe_topics( self.hass, @@ -315,11 +314,6 @@ class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity): 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 cb586c06309..9a0ce2077f3 100644 --- a/homeassistant/components/mqtt/lock.py +++ b/homeassistant/components/mqtt/lock.py @@ -28,12 +28,18 @@ from .const import ( CONF_COMMAND_TEMPLATE, CONF_COMMAND_TOPIC, CONF_ENCODING, + CONF_PAYLOAD_RESET, CONF_QOS, CONF_RETAIN, CONF_STATE_TOPIC, ) from .debug_info import log_messages -from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper +from .mixins import ( + MQTT_ENTITY_COMMON_SCHEMA, + MqttEntity, + async_setup_entry_helper, + write_state_on_attr_change, +) from .models import ( MqttCommandTemplate, MqttValueTemplate, @@ -41,7 +47,6 @@ from .models import ( ReceiveMessage, ReceivePayloadType, ) -from .util import get_mqtt_data CONF_CODE_FORMAT = "code_format" @@ -59,6 +64,7 @@ DEFAULT_NAME = "MQTT Lock" DEFAULT_PAYLOAD_LOCK = "LOCK" DEFAULT_PAYLOAD_UNLOCK = "UNLOCK" DEFAULT_PAYLOAD_OPEN = "OPEN" +DEFAULT_PAYLOAD_RESET = "None" DEFAULT_STATE_LOCKED = "LOCKED" DEFAULT_STATE_LOCKING = "LOCKING" DEFAULT_STATE_UNLOCKED = "UNLOCKED" @@ -80,6 +86,7 @@ PLATFORM_SCHEMA_MODERN = MQTT_RW_SCHEMA.extend( vol.Optional(CONF_PAYLOAD_LOCK, default=DEFAULT_PAYLOAD_LOCK): cv.string, vol.Optional(CONF_PAYLOAD_UNLOCK, default=DEFAULT_PAYLOAD_UNLOCK): cv.string, vol.Optional(CONF_PAYLOAD_OPEN): cv.string, + vol.Optional(CONF_PAYLOAD_RESET, default=DEFAULT_PAYLOAD_RESET): cv.string, vol.Optional(CONF_STATE_JAMMED, default=DEFAULT_STATE_JAMMED): cv.string, vol.Optional(CONF_STATE_LOCKED, default=DEFAULT_STATE_LOCKED): cv.string, vol.Optional(CONF_STATE_LOCKING, default=DEFAULT_STATE_LOCKING): cv.string, @@ -138,17 +145,6 @@ class MqttLock(MqttEntity, LockEntity): ] _value_template: Callable[[ReceivePayloadType], ReceivePayloadType] - def __init__( - self, - hass: HomeAssistant, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None, - ) -> None: - """Initialize the lock.""" - self._attr_is_locked = False - MqttEntity.__init__(self, hass, config, config_entry, discovery_data) - @staticmethod def config_schema() -> vol.Schema: """Return the config schema.""" @@ -156,9 +152,13 @@ class MqttLock(MqttEntity, LockEntity): def _setup_from_config(self, config: ConfigType) -> None: """(Re)Setup the entity.""" - self._optimistic = ( - config[CONF_OPTIMISTIC] or self._config.get(CONF_STATE_TOPIC) is None - ) + if ( + optimistic := config[CONF_OPTIMISTIC] + or config.get(CONF_STATE_TOPIC) is None + ): + self._attr_is_locked = False + self._optimistic = optimistic + self._attr_assumed_state = bool(optimistic) self._compiled_pattern = config.get(CONF_CODE_FORMAT) self._attr_code_format = ( @@ -189,17 +189,28 @@ class MqttLock(MqttEntity, LockEntity): @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change( + self, + { + "_attr_is_jammed", + "_attr_is_locked", + "_attr_is_locking", + "_attr_is_unlocking", + }, + ) def message_received(msg: ReceiveMessage) -> None: """Handle new lock state messages.""" - payload = self._value_template(msg.payload) - if payload in self._valid_states: + if (payload := self._value_template(msg.payload)) == self._config[ + CONF_PAYLOAD_RESET + ]: + # Reset the state to `unknown` + self._attr_is_locked = None + elif payload in self._valid_states: self._attr_is_locked = payload == self._config[CONF_STATE_LOCKED] self._attr_is_locking = payload == self._config[CONF_STATE_LOCKING] self._attr_is_unlocking = payload == self._config[CONF_STATE_UNLOCKING] self._attr_is_jammed = payload == self._config[CONF_STATE_JAMMED] - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) - if self._config.get(CONF_STATE_TOPIC) is None: # Force into optimistic mode. self._optimistic = True @@ -221,11 +232,6 @@ class MqttLock(MqttEntity, LockEntity): """(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 4eae1fae30c..a01691f0601 100644 --- a/homeassistant/components/mqtt/mixins.py +++ b/homeassistant/components/mqtt/mixins.py @@ -4,9 +4,9 @@ from __future__ import annotations 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 @@ from .discovery import ( 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 @@ class MqttAttributes(Entity): @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 @@ class MqttAttributes(Entity): 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 @@ class MqttAvailability(Entity): @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 @@ class MqttAvailability(Entity): 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 @@ class MqttDiscoveryUpdate(Entity): 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 @@ class MqttDiscoveryUpdate(Entity): 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 ) diff --git a/homeassistant/components/mqtt/number.py b/homeassistant/components/mqtt/number.py index 971b44b43bf..231da95ffb0 100644 --- a/homeassistant/components/mqtt/number.py +++ b/homeassistant/components/mqtt/number.py @@ -42,7 +42,12 @@ from .const import ( CONF_STATE_TOPIC, ) from .debug_info import log_messages -from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper +from .mixins import ( + MQTT_ENTITY_COMMON_SCHEMA, + MqttEntity, + async_setup_entry_helper, + write_state_on_attr_change, +) from .models import ( MqttCommandTemplate, MqttValueTemplate, @@ -50,7 +55,6 @@ from .models import ( ReceiveMessage, ReceivePayloadType, ) -from .util import get_mqtt_data _LOGGER = logging.getLogger(__name__) @@ -142,17 +146,6 @@ class MqttNumber(MqttEntity, RestoreNumber): _command_template: Callable[[PublishPayloadType], PublishPayloadType] _value_template: Callable[[ReceivePayloadType], ReceivePayloadType] - def __init__( - self, - hass: HomeAssistant, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None, - ) -> None: - """Initialize the MQTT Number.""" - RestoreNumber.__init__(self) - MqttEntity.__init__(self, hass, config, config_entry, discovery_data) - @staticmethod def config_schema() -> vol.Schema: """Return the config schema.""" @@ -161,7 +154,7 @@ class MqttNumber(MqttEntity, RestoreNumber): 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 @@ -183,6 +176,7 @@ class MqttNumber(MqttEntity, RestoreNumber): @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_native_value"}) def message_received(msg: ReceiveMessage) -> None: """Handle new MQTT messages.""" num_value: int | float | None @@ -214,11 +208,10 @@ class MqttNumber(MqttEntity, RestoreNumber): return self._attr_native_value = num_value - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) 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 +230,7 @@ class MqttNumber(MqttEntity, RestoreNumber): """(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 +243,7 @@ class MqttNumber(MqttEntity, RestoreNumber): 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 +254,3 @@ class MqttNumber(MqttEntity, RestoreNumber): 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/scene.py b/homeassistant/components/mqtt/scene.py index fd876976fe6..9e7c280cbc0 100644 --- a/homeassistant/components/mqtt/scene.py +++ b/homeassistant/components/mqtt/scene.py @@ -69,16 +69,6 @@ class MqttScene( _default_name = DEFAULT_NAME _entity_id_format = scene.DOMAIN + ".{}" - def __init__( - self, - hass: HomeAssistant, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None, - ) -> None: - """Initialize the MQTT scene.""" - MqttEntity.__init__(self, hass, config, config_entry, discovery_data) - @staticmethod def config_schema() -> vol.Schema: """Return the config schema.""" diff --git a/homeassistant/components/mqtt/select.py b/homeassistant/components/mqtt/select.py index df8cf024bd2..03cd529fdd0 100644 --- a/homeassistant/components/mqtt/select.py +++ b/homeassistant/components/mqtt/select.py @@ -28,7 +28,12 @@ from .const import ( CONF_STATE_TOPIC, ) from .debug_info import log_messages -from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper +from .mixins import ( + MQTT_ENTITY_COMMON_SCHEMA, + MqttEntity, + async_setup_entry_helper, + write_state_on_attr_change, +) from .models import ( MqttCommandTemplate, MqttValueTemplate, @@ -36,7 +41,6 @@ from .models import ( ReceiveMessage, ReceivePayloadType, ) -from .util import get_mqtt_data _LOGGER = logging.getLogger(__name__) @@ -89,6 +93,7 @@ async def _async_setup_entity( class MqttSelect(MqttEntity, SelectEntity, RestoreEntity): """representation of an MQTT select.""" + _attr_current_option: str | None = None _default_name = DEFAULT_NAME _entity_id_format = select.ENTITY_ID_FORMAT _attributes_extra_blocked = MQTT_SELECT_ATTRIBUTES_BLOCKED @@ -96,18 +101,6 @@ class MqttSelect(MqttEntity, SelectEntity, RestoreEntity): _value_template: Callable[[ReceivePayloadType], ReceivePayloadType] _optimistic: bool = False - def __init__( - self, - hass: HomeAssistant, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None, - ) -> None: - """Initialize the MQTT select.""" - self._attr_current_option = None - SelectEntity.__init__(self) - MqttEntity.__init__(self, hass, config, config_entry, discovery_data) - @staticmethod def config_schema() -> vol.Schema: """Return the config schema.""" @@ -115,7 +108,7 @@ class MqttSelect(MqttEntity, SelectEntity, RestoreEntity): 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( @@ -131,12 +124,12 @@ class MqttSelect(MqttEntity, SelectEntity, RestoreEntity): @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_current_option"}) def message_received(msg: ReceiveMessage) -> None: """Handle new MQTT messages.""" payload = str(self._value_template(msg.payload)) if payload.lower() == "none": self._attr_current_option = None - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) return if payload not in self.options: @@ -148,11 +141,10 @@ class MqttSelect(MqttEntity, SelectEntity, RestoreEntity): ) return self._attr_current_option = payload - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) 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 +163,15 @@ class MqttSelect(MqttEntity, SelectEntity, RestoreEntity): """(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 +182,3 @@ class MqttSelect(MqttEntity, SelectEntity, RestoreEntity): 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 ae94b0df0ce..05db22a8e62 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -32,7 +32,7 @@ from homeassistant.const import ( 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 @@ from .mixins import ( MqttAvailability, MqttEntity, async_setup_entry_helper, + write_state_on_attr_change, ) from .models import ( MqttValueTemplate, @@ -52,7 +53,6 @@ from .models import ( ReceiveMessage, ReceivePayloadType, ) -from .util import get_mqtt_data _LOGGER = logging.getLogger(__name__) @@ -130,22 +130,12 @@ class MqttSensor(MqttEntity, RestoreSensor): _entity_id_format = ENTITY_ID_FORMAT _attr_last_reset: datetime | None = None _attributes_extra_blocked = MQTT_SENSOR_ATTRIBUTES_BLOCKED + _expiration_trigger: CALLBACK_TYPE | None = None _expire_after: int | None _expired: bool | None _template: Callable[[ReceivePayloadType, PayloadSentinel], ReceivePayloadType] _last_reset_template: Callable[[ReceivePayloadType], ReceivePayloadType] - def __init__( - self, - hass: HomeAssistant, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None, - ) -> None: - """Initialize the sensor.""" - self._expiration_trigger: CALLBACK_TYPE | None = None - MqttEntity.__init__(self, hass, config, config_entry, discovery_data) - async def mqtt_async_added_to_hass(self) -> None: """Restore state for entities with expire_after set.""" last_state: State | None @@ -162,15 +152,17 @@ class MqttSensor(MqttEntity, RestoreSensor): 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 +170,7 @@ class MqttSensor(MqttEntity, RestoreSensor): " expiring %s" ), self.entity_id, - expiration_at - time_now, + remain_seconds, ) async def async_will_remove_from_hass(self) -> None: @@ -235,10 +227,8 @@ class MqttSensor(MqttEntity, RestoreSensor): 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 +277,13 @@ class MqttSensor(MqttEntity, RestoreSensor): ) @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 328812a6e49..7978776a089 100644 --- a/homeassistant/components/mqtt/siren.py +++ b/homeassistant/components/mqtt/siren.py @@ -49,7 +49,12 @@ from .const import ( PAYLOAD_NONE, ) from .debug_info import log_messages -from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper +from .mixins import ( + MQTT_ENTITY_COMMON_SCHEMA, + MqttEntity, + async_setup_entry_helper, + write_state_on_attr_change, +) from .models import ( MqttCommandTemplate, MqttValueTemplate, @@ -57,7 +62,6 @@ from .models import ( ReceiveMessage, ReceivePayloadType, ) -from .util import get_mqtt_data DEFAULT_NAME = "MQTT Siren" DEFAULT_PAYLOAD_ON = "ON" @@ -151,17 +155,6 @@ class MqttSiren(MqttEntity, SirenEntity): _state_off: str _optimistic: bool - def __init__( - self, - hass: HomeAssistant, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None, - ) -> None: - """Initialize the MQTT siren.""" - self._extra_attributes: dict[str, Any] = {} - MqttEntity.__init__(self, hass, config, config_entry, discovery_data) - @staticmethod def config_schema() -> vol.Schema: """Return the config schema.""" @@ -194,6 +187,7 @@ class MqttSiren(MqttEntity, SirenEntity): 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) @@ -222,6 +216,7 @@ class MqttSiren(MqttEntity, SirenEntity): @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_is_on", "_extra_attributes"}) def state_message_received(msg: ReceiveMessage) -> None: """Handle new MQTT state messages.""" payload = self._value_template(msg.payload) @@ -277,8 +272,10 @@ class MqttSiren(MqttEntity, SirenEntity): invalid_siren_parameters, ) return + # To be able to track changes to self._extra_attributes we assign + # a fresh copy to make the original tracked reference immutable. + self._extra_attributes = dict(self._extra_attributes) self._update(process_turn_on_params(self, params)) - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) if self._config.get(CONF_STATE_TOPIC) is None: # Force into optimistic mode. @@ -301,11 +298,6 @@ class MqttSiren(MqttEntity, SirenEntity): """(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.""" @@ -383,6 +375,7 @@ class MqttSiren(MqttEntity, SirenEntity): """Update the extra siren state attributes.""" for attribute, support in SUPPORTED_ATTRIBUTES.items(): if self._attr_supported_features & support and attribute in data: - self._extra_attributes[attribute] = data[ - attribute # type: ignore[literal-required] - ] + data_attr = data[attribute] # type: ignore[literal-required] + if self._extra_attributes.get(attribute) == data_attr: + continue + self._extra_attributes[attribute] = data_attr diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index d1b63b331ed..b28f16cb404 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -18,7 +18,7 @@ }, "deprecated_climate_aux_property": { "title": "MQTT entities with auxiliary heat support found", - "description": "Entity `{entity_id}` has auxiliary heat support enabled, which has been deprecated for MQTT climate devices. Please adjust your configuration and remove deperated config options from your configration and restart HA to fix this issue." + "description": "Entity `{entity_id}` has auxiliary heat support enabled, which has been deprecated for MQTT climate devices. Please adjust your configuration and remove deprecated config options from your configuration and restart Home Assistant to fix this issue." } }, "config": { diff --git a/homeassistant/components/mqtt/subscription.py b/homeassistant/components/mqtt/subscription.py index dda80bba84e..3f8f0f4ee3e 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 @@ class EntitySubscription: ) -> 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 107b0b1cb10..d4e8f2609d9 100644 --- a/homeassistant/components/mqtt/switch.py +++ b/homeassistant/components/mqtt/switch.py @@ -37,9 +37,13 @@ from .const import ( PAYLOAD_NONE, ) from .debug_info import log_messages -from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper +from .mixins import ( + MQTT_ENTITY_COMMON_SCHEMA, + MqttEntity, + async_setup_entry_helper, + write_state_on_attr_change, +) from .models import MqttValueTemplate, ReceiveMessage -from .util import get_mqtt_data DEFAULT_NAME = "MQTT Switch" DEFAULT_PAYLOAD_ON = "ON" @@ -96,17 +100,6 @@ class MqttSwitch(MqttEntity, SwitchEntity, RestoreEntity): _state_off: str _value_template: Callable[[ReceivePayloadType], ReceivePayloadType] - def __init__( - self, - hass: HomeAssistant, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None, - ) -> None: - """Initialize the MQTT switch.""" - - MqttEntity.__init__(self, hass, config, config_entry, discovery_data) - @staticmethod def config_schema() -> vol.Schema: """Return the config schema.""" @@ -125,6 +118,7 @@ class MqttSwitch(MqttEntity, SwitchEntity, RestoreEntity): 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 @@ -135,6 +129,7 @@ class MqttSwitch(MqttEntity, SwitchEntity, RestoreEntity): @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 new MQTT state messages.""" payload = self._value_template(msg.payload) @@ -145,8 +140,6 @@ class MqttSwitch(MqttEntity, SwitchEntity, RestoreEntity): elif payload == PAYLOAD_NONE: self._attr_is_on = None - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) - if self._config.get(CONF_STATE_TOPIC) is None: # Force into optimistic mode. self._optimistic = True @@ -171,11 +164,6 @@ class MqttSwitch(MqttEntity, SwitchEntity, RestoreEntity): 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 13677b7f35b..630951f171e 100644 --- a/homeassistant/components/mqtt/text.py +++ b/homeassistant/components/mqtt/text.py @@ -35,7 +35,12 @@ from .const import ( CONF_STATE_TOPIC, ) from .debug_info import log_messages -from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper +from .mixins import ( + MQTT_ENTITY_COMMON_SCHEMA, + MqttEntity, + async_setup_entry_helper, + write_state_on_attr_change, +) from .models import ( MessageCallbackType, MqttCommandTemplate, @@ -44,7 +49,6 @@ from .models import ( ReceiveMessage, ReceivePayloadType, ) -from .util import get_mqtt_data _LOGGER = logging.getLogger(__name__) @@ -124,6 +128,7 @@ async def _async_setup_entity( class MqttTextEntity(MqttEntity, TextEntity): """Representation of the MQTT text entity.""" + _attr_native_value: str | None = None _attributes_extra_blocked = MQTT_TEXT_ATTRIBUTES_BLOCKED _default_name = DEFAULT_NAME _entity_id_format = text.ENTITY_ID_FORMAT @@ -133,17 +138,6 @@ class MqttTextEntity(MqttEntity, TextEntity): _command_template: Callable[[PublishPayloadType], PublishPayloadType] _value_template: Callable[[ReceivePayloadType], ReceivePayloadType] - def __init__( - self, - hass: HomeAssistant, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None = None, - ) -> None: - """Initialize MQTT text entity.""" - self._attr_native_value = None - MqttEntity.__init__(self, hass, config, config_entry, discovery_data) - @staticmethod def config_schema() -> vol.Schema: """Return the config schema.""" @@ -169,6 +163,7 @@ class MqttTextEntity(MqttEntity, TextEntity): ).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.""" @@ -187,11 +182,11 @@ class MqttTextEntity(MqttEntity, TextEntity): @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_native_value"}) def handle_state_message_received(msg: ReceiveMessage) -> None: """Handle receiving state message via MQTT.""" payload = str(self._value_template(msg.payload)) self._attr_native_value = payload - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) add_subscription(topics, CONF_STATE_TOPIC, handle_state_message_received) @@ -203,11 +198,6 @@ class MqttTextEntity(MqttEntity, TextEntity): """(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/update.py b/homeassistant/components/mqtt/update.py index f6db0d3fd64..45cca7279f9 100644 --- a/homeassistant/components/mqtt/update.py +++ b/homeassistant/components/mqtt/update.py @@ -33,9 +33,14 @@ from .const import ( PAYLOAD_EMPTY_JSON, ) from .debug_info import log_messages -from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper +from .mixins import ( + MQTT_ENTITY_COMMON_SCHEMA, + MqttEntity, + async_setup_entry_helper, + write_state_on_attr_change, +) from .models import MessageCallbackType, MqttValueTemplate, ReceiveMessage -from .util import get_mqtt_data, valid_publish_topic, valid_subscribe_topic +from .util import valid_publish_topic, valid_subscribe_topic _LOGGER = logging.getLogger(__name__) @@ -109,24 +114,7 @@ class MqttUpdate(MqttEntity, UpdateEntity, RestoreEntity): _default_name = DEFAULT_NAME _entity_id_format = update.ENTITY_ID_FORMAT - - def __init__( - self, - hass: HomeAssistant, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None = None, - ) -> None: - """Initialize the MQTT update.""" - self._config = config - self._attr_device_class = self._config.get(CONF_DEVICE_CLASS) - self._attr_release_summary = self._config.get(CONF_RELEASE_SUMMARY) - self._attr_release_url = self._config.get(CONF_RELEASE_URL) - self._attr_title = self._config.get(CONF_TITLE) - self._entity_picture: str | None = self._config.get(CONF_ENTITY_PICTURE) - - UpdateEntity.__init__(self) - MqttEntity.__init__(self, hass, config, config_entry, discovery_data) + _entity_picture: str | None @property def entity_picture(self) -> str | None: @@ -143,6 +131,11 @@ class MqttUpdate(MqttEntity, UpdateEntity, RestoreEntity): def _setup_from_config(self, config: ConfigType) -> None: """(Re)Setup the entity.""" + self._attr_device_class = self._config.get(CONF_DEVICE_CLASS) + self._attr_release_summary = self._config.get(CONF_RELEASE_SUMMARY) + self._attr_release_url = self._config.get(CONF_RELEASE_URL) + self._attr_title = self._config.get(CONF_TITLE) + self._entity_picture: str | None = self._config.get(CONF_ENTITY_PICTURE) self._templates = { CONF_VALUE_TEMPLATE: MqttValueTemplate( config.get(CONF_VALUE_TEMPLATE), @@ -171,6 +164,17 @@ class MqttUpdate(MqttEntity, UpdateEntity, RestoreEntity): @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change( + self, + { + "_attr_installed_version", + "_attr_latest_version", + "_attr_title", + "_attr_release_summary", + "_attr_release_url", + "_entity_picture", + }, + ) def handle_state_message_received(msg: ReceiveMessage) -> None: """Handle receiving state message via MQTT.""" payload = self._templates[CONF_VALUE_TEMPLATE](msg.payload) @@ -219,39 +223,33 @@ class MqttUpdate(MqttEntity, UpdateEntity, RestoreEntity): if "installed_version" in json_payload: self._attr_installed_version = json_payload["installed_version"] - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) if "latest_version" in json_payload: self._attr_latest_version = json_payload["latest_version"] - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) if "title" in json_payload: self._attr_title = json_payload["title"] - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) if "release_summary" in json_payload: self._attr_release_summary = json_payload["release_summary"] - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) if "release_url" in json_payload: self._attr_release_url = json_payload["release_url"] - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) if "entity_picture" in json_payload: self._entity_picture = json_payload["entity_picture"] - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) add_subscription(topics, CONF_STATE_TOPIC, handle_state_message_received) @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_latest_version"}) def handle_latest_version_received(msg: ReceiveMessage) -> None: """Handle receiving latest version via MQTT.""" latest_version = self._templates[CONF_LATEST_VERSION_TEMPLATE](msg.payload) if isinstance(latest_version, str) and latest_version != "": self._attr_latest_version = latest_version - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) add_subscription( topics, CONF_LATEST_VERSION_TOPIC, handle_latest_version_received @@ -279,8 +277,6 @@ class MqttUpdate(MqttEntity, UpdateEntity, RestoreEntity): self._config[CONF_ENCODING], ) - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) - @property def supported_features(self) -> UpdateEntityFeature: """Return the list of supported features.""" diff --git a/homeassistant/components/mqtt/util.py b/homeassistant/components/mqtt/util.py index 02d9964bcd1..6e364182cb0 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/vacuum/schema_legacy.py b/homeassistant/components/mqtt/vacuum/schema_legacy.py index 516a7772c11..aee71cc6690 100644 --- a/homeassistant/components/mqtt/vacuum/schema_legacy.py +++ b/homeassistant/components/mqtt/vacuum/schema_legacy.py @@ -30,14 +30,14 @@ from .. import subscription from ..config import MQTT_BASE_SCHEMA from ..const import CONF_COMMAND_TOPIC, CONF_ENCODING, CONF_QOS, CONF_RETAIN from ..debug_info import log_messages -from ..mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity +from ..mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, write_state_on_attr_change from ..models import ( MqttValueTemplate, PayloadSentinel, ReceiveMessage, ReceivePayloadType, ) -from ..util import get_mqtt_data, valid_publish_topic +from ..util import valid_publish_topic from .const import MQTT_VACUUM_ATTRIBUTES_BLOCKED from .schema import MQTT_VACUUM_SCHEMA, services_to_strings, strings_to_services @@ -215,12 +215,17 @@ async def async_setup_entity_legacy( class MqttVacuum(MqttEntity, VacuumEntity): """Representation of a MQTT-controlled legacy vacuum.""" + _attr_battery_level = 0 + _attr_is_on = False + _attributes_extra_blocked = MQTT_LEGACY_VACUUM_ATTRIBUTES_BLOCKED + _charging: bool = False + _cleaning: bool = False + _command_topic: str | None + _docked: bool = False _default_name = DEFAULT_NAME _entity_id_format = ENTITY_ID_FORMAT - _attributes_extra_blocked = MQTT_LEGACY_VACUUM_ATTRIBUTES_BLOCKED - - _command_topic: str | None _encoding: str | None + _error: str | None = None _qos: bool _retain: bool _payloads: dict[str, str] @@ -231,25 +236,6 @@ class MqttVacuum(MqttEntity, VacuumEntity): str, Callable[[ReceivePayloadType, PayloadSentinel], ReceivePayloadType] ] - def __init__( - self, - hass: HomeAssistant, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None, - ) -> None: - """Initialize the vacuum.""" - self._attr_battery_level = 0 - self._attr_is_on = False - self._attr_fan_speed = "unknown" - - self._charging = False - self._cleaning = False - self._docked = False - self._error: str | None = None - - MqttEntity.__init__(self, hass, config, config_entry, discovery_data) - @staticmethod def config_schema() -> vol.Schema: """Return the config schema.""" @@ -313,6 +299,20 @@ class MqttVacuum(MqttEntity, VacuumEntity): @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change( + self, + { + "_attr_battery_level", + "_attr_fan_speed", + "_attr_is_on", + # We track _attr_status and _charging as they are used to + # To determine the batery_icon. + # We do not need to track _docked as it is + # not leading to entity changes directly. + "_attr_status", + "_charging", + }, + ) def message_received(msg: ReceiveMessage) -> None: """Handle new MQTT message.""" if ( @@ -387,8 +387,6 @@ class MqttVacuum(MqttEntity, VacuumEntity): if fan_speed and fan_speed is not PayloadSentinel.DEFAULT: self._attr_fan_speed = str(fan_speed) - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) - topics_list = {topic for topic in self._state_topics.values() if topic} self._sub_state = subscription.async_prepare_subscribe_topics( self.hass, diff --git a/homeassistant/components/mqtt/vacuum/schema_state.py b/homeassistant/components/mqtt/vacuum/schema_state.py index 5113e19f097..425202adea2 100644 --- a/homeassistant/components/mqtt/vacuum/schema_state.py +++ b/homeassistant/components/mqtt/vacuum/schema_state.py @@ -38,9 +38,9 @@ from ..const import ( CONF_STATE_TOPIC, ) from ..debug_info import log_messages -from ..mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity +from ..mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, write_state_on_attr_change from ..models import ReceiveMessage -from ..util import get_mqtt_data, valid_publish_topic +from ..util import valid_publish_topic from .const import MQTT_VACUUM_ATTRIBUTES_BLOCKED from .schema import MQTT_VACUUM_SCHEMA, services_to_strings, strings_to_services @@ -231,6 +231,9 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity): @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change( + self, {"_attr_battery_level", "_attr_fan_speed", "_attr_state"} + ) def state_message_received(msg: ReceiveMessage) -> None: """Handle state MQTT message.""" payload = json_loads_object(msg.payload) @@ -242,7 +245,6 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity): ) del payload[STATE] self._update_state_attributes(payload) - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) if state_topic := self._config.get(CONF_STATE_TOPIC): topics["state_position_topic"] = { diff --git a/homeassistant/components/mqtt/water_heater.py b/homeassistant/components/mqtt/water_heater.py index 08b9d36d850..9a9326d6d07 100644 --- a/homeassistant/components/mqtt/water_heater.py +++ b/homeassistant/components/mqtt/water_heater.py @@ -65,9 +65,13 @@ from .const import ( DEFAULT_OPTIMISTIC, ) from .debug_info import log_messages -from .mixins import MQTT_ENTITY_COMMON_SCHEMA, async_setup_entry_helper +from .mixins import ( + MQTT_ENTITY_COMMON_SCHEMA, + async_setup_entry_helper, + write_state_on_attr_change, +) from .models import MqttCommandTemplate, MqttValueTemplate, ReceiveMessage -from .util import get_mqtt_data, valid_publish_topic, valid_subscribe_topic +from .util import valid_publish_topic, valid_subscribe_topic _LOGGER = logging.getLogger(__name__) @@ -190,18 +194,6 @@ class MqttWaterHeater(MqttTemperatureControlEntity, WaterHeaterEntity): _entity_id_format = water_heater.ENTITY_ID_FORMAT _attributes_extra_blocked = MQTT_WATER_HEATER_ATTRIBUTES_BLOCKED - def __init__( - self, - hass: HomeAssistant, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None, - ) -> None: - """Initialize the water heater device.""" - MqttTemperatureControlEntity.__init__( - self, hass, config, config_entry, discovery_data - ) - @staticmethod def config_schema() -> vol.Schema: """Return the config schema.""" @@ -292,10 +284,10 @@ class MqttWaterHeater(MqttTemperatureControlEntity, WaterHeaterEntity): _LOGGER.error("Invalid %s mode: %s", mode_list, payload) else: setattr(self, attr, payload) - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_current_operation"}) def handle_current_mode_received(msg: ReceiveMessage) -> None: """Handle receiving operation mode via MQTT.""" handle_mode_received( diff --git a/homeassistant/components/mqtt_room/sensor.py b/homeassistant/components/mqtt_room/sensor.py index 1b4cdb1c583..4eb3a3f5171 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 @@ import homeassistant.helpers.config_validation as cv 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 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( } ).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 @@ class MQTTRoomSensor(SensorEntity): 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 @@ class MQTTRoomSensor(SensorEntity): 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 444643d5333..910f91fc4c6 100644 --- a/homeassistant/components/mutesync/binary_sensor.py +++ b/homeassistant/components/mutesync/binary_sensor.py @@ -36,24 +36,17 @@ class MuteStatus(update_coordinator.CoordinatorEntity, BinarySensorEntity): 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}" + 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, 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] - - @property - def device_info(self) -> DeviceInfo: - """Return the device info of the sensor.""" - return DeviceInfo( - entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, self.coordinator.data["user-id"])}, - manufacturer="mütesync", - model="mutesync app", - name="mutesync", - ) diff --git a/homeassistant/components/myq/const.py b/homeassistant/components/myq/const.py index 3c7b5ba373a..16dead34477 100644 --- a/homeassistant/components/myq/const.py +++ b/homeassistant/components/myq/const.py @@ -33,14 +33,4 @@ MYQ_TO_HASS = { MYQ_GATEWAY = "myq_gateway" MYQ_COORDINATOR = "coordinator" -# myq has some ratelimits in place -# and 61 seemed to be work every time -UPDATE_INTERVAL = 15 - -# Estimated time it takes myq to start transition from one -# state to the next. -TRANSITION_START_DURATION = 7 - -# Estimated time it takes myq to complete a transition -# from one state to another -TRANSITION_COMPLETE_DURATION = 37 +UPDATE_INTERVAL = 30 diff --git a/homeassistant/components/myq/manifest.json b/homeassistant/components/myq/manifest.json index 5e03f962d15..5efcb8e1bb0 100644 --- a/homeassistant/components/myq/manifest.json +++ b/homeassistant/components/myq/manifest.json @@ -1,7 +1,7 @@ { "domain": "myq", "name": "MyQ", - "codeowners": ["@ehendrix23"], + "codeowners": ["@ehendrix23", "@Lash-L"], "config_flow": true, "dhcp": [ { @@ -14,5 +14,5 @@ }, "iot_class": "cloud_polling", "loggers": ["pkce", "pymyq"], - "requirements": ["pymyq==3.1.4"] + "requirements": ["python-myq==3.1.11"] } diff --git a/homeassistant/components/mysensors/__init__.py b/homeassistant/components/mysensors/__init__.py index 5b8154e17aa..a3f52cd28ab 100644 --- a/homeassistant/components/mysensors/__init__.py +++ b/homeassistant/components/mysensors/__init__.py @@ -15,6 +15,7 @@ from homeassistant.helpers.device_registry import DeviceEntry from .const import ( ATTR_DEVICES, DOMAIN, + MYSENSORS_DISCOVERED_NODES, MYSENSORS_GATEWAYS, MYSENSORS_ON_UNLOAD, PLATFORMS, @@ -22,7 +23,7 @@ from .const import ( DiscoveryInfo, SensorType, ) -from .device import MySensorsEntity, get_mysensors_devices +from .device import MySensorsChildEntity, get_mysensors_devices from .gateway import finish_setup, gw_stop, setup_gateway _LOGGER = logging.getLogger(__name__) @@ -72,6 +73,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(key) del hass.data[DOMAIN][MYSENSORS_GATEWAYS][entry.entry_id] + hass.data[DOMAIN].pop(MYSENSORS_DISCOVERED_NODES.format(entry.entry_id), None) await gw_stop(hass, entry, gateway) return True @@ -91,6 +93,11 @@ async def async_remove_config_entry_device( gateway.sensors.pop(node_id, None) gateway.tasks.persistence.need_save = True + # remove node from discovered nodes + hass.data[DOMAIN].setdefault( + MYSENSORS_DISCOVERED_NODES.format(config_entry.entry_id), set() + ).remove(node_id) + return True @@ -99,12 +106,13 @@ def setup_mysensors_platform( hass: HomeAssistant, domain: Platform, # hass platform name discovery_info: DiscoveryInfo, - device_class: type[MySensorsEntity] | Mapping[SensorType, type[MySensorsEntity]], + device_class: type[MySensorsChildEntity] + | Mapping[SensorType, type[MySensorsChildEntity]], device_args: ( None | tuple ) = None, # extra arguments that will be given to the entity constructor async_add_entities: Callable | None = None, -) -> list[MySensorsEntity] | None: +) -> list[MySensorsChildEntity] | None: """Set up a MySensors platform. Sets up a bunch of instances of a single platform that is supported by this @@ -118,10 +126,10 @@ def setup_mysensors_platform( """ if device_args is None: device_args = () - new_devices: list[MySensorsEntity] = [] + new_devices: list[MySensorsChildEntity] = [] new_dev_ids: list[DevId] = discovery_info[ATTR_DEVICES] for dev_id in new_dev_ids: - devices: dict[DevId, MySensorsEntity] = get_mysensors_devices(hass, domain) + devices: dict[DevId, MySensorsChildEntity] = get_mysensors_devices(hass, domain) if dev_id in devices: _LOGGER.debug( "Skipping setup of %s for platform %s as it already exists", diff --git a/homeassistant/components/mysensors/binary_sensor.py b/homeassistant/components/mysensors/binary_sensor.py index d8f4ec07cb2..2b4edd99221 100644 --- a/homeassistant/components/mysensors/binary_sensor.py +++ b/homeassistant/components/mysensors/binary_sensor.py @@ -95,7 +95,7 @@ async def async_setup_entry( ) -class MySensorsBinarySensor(mysensors.device.MySensorsEntity, BinarySensorEntity): +class MySensorsBinarySensor(mysensors.device.MySensorsChildEntity, BinarySensorEntity): """Representation of a MySensors binary sensor child node.""" entity_description: MySensorsBinarySensorDescription diff --git a/homeassistant/components/mysensors/climate.py b/homeassistant/components/mysensors/climate.py index d207d7ff550..e9d4502242e 100644 --- a/homeassistant/components/mysensors/climate.py +++ b/homeassistant/components/mysensors/climate.py @@ -66,7 +66,7 @@ async def async_setup_entry( ) -class MySensorsHVAC(mysensors.device.MySensorsEntity, ClimateEntity): +class MySensorsHVAC(mysensors.device.MySensorsChildEntity, ClimateEntity): """Representation of a MySensors HVAC.""" _attr_hvac_modes = OPERATION_LIST diff --git a/homeassistant/components/mysensors/const.py b/homeassistant/components/mysensors/const.py index 7f9326091fe..a5c82c32b55 100644 --- a/homeassistant/components/mysensors/const.py +++ b/homeassistant/components/mysensors/const.py @@ -8,6 +8,7 @@ from homeassistant.const import Platform ATTR_DEVICES: Final = "devices" ATTR_GATEWAY_ID: Final = "gateway_id" +ATTR_NODE_ID: Final = "node_id" CONF_BAUD_RATE: Final = "baud_rate" CONF_DEVICE: Final = "device" @@ -26,11 +27,13 @@ CONF_GATEWAY_TYPE_MQTT: ConfGatewayType = "MQTT" DOMAIN: Final = "mysensors" MYSENSORS_GATEWAY_START_TASK: str = "mysensors_gateway_start_task_{}" MYSENSORS_GATEWAYS: Final = "mysensors_gateways" +MYSENSORS_DISCOVERED_NODES: Final = "mysensors_discovered_nodes_{}" PLATFORM: Final = "platform" SCHEMA: Final = "schema" CHILD_CALLBACK: str = "mysensors_child_callback_{}_{}_{}_{}" NODE_CALLBACK: str = "mysensors_node_callback_{}_{}" MYSENSORS_DISCOVERY: str = "mysensors_discovery_{}_{}" +MYSENSORS_NODE_DISCOVERY: str = "mysensors_node_discovery" MYSENSORS_ON_UNLOAD: str = "mysensors_on_unload_{}" TYPE: Final = "type" UPDATE_DELAY: float = 0.1 @@ -43,6 +46,13 @@ class DiscoveryInfo(TypedDict): gateway_id: GatewayId +class NodeDiscoveryInfo(TypedDict): + """Represent discovered mysensors node.""" + + gateway_id: GatewayId + node_id: int + + SERVICE_SEND_IR_CODE: Final = "send_ir_code" SensorType = str diff --git a/homeassistant/components/mysensors/cover.py b/homeassistant/components/mysensors/cover.py index a1b2cb303ed..8be5f1f8620 100644 --- a/homeassistant/components/mysensors/cover.py +++ b/homeassistant/components/mysensors/cover.py @@ -54,7 +54,7 @@ async def async_setup_entry( ) -class MySensorsCover(mysensors.device.MySensorsEntity, CoverEntity): +class MySensorsCover(mysensors.device.MySensorsChildEntity, CoverEntity): """Representation of the value of a MySensors Cover child node.""" def get_cover_state(self) -> CoverState: diff --git a/homeassistant/components/mysensors/device.py b/homeassistant/components/mysensors/device.py index a89de3abf69..9e1d91c7cce 100644 --- a/homeassistant/components/mysensors/device.py +++ b/homeassistant/components/mysensors/device.py @@ -1,14 +1,14 @@ """Handle MySensors devices.""" from __future__ import annotations -from abc import ABC, abstractmethod +from abc import abstractmethod import logging from typing import Any from mysensors import BaseAsyncGateway, Sensor from mysensors.sensor import ChildSensor -from homeassistant.const import ATTR_BATTERY_LEVEL, STATE_OFF, STATE_ON, Platform +from homeassistant.const import STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.device_registry import DeviceInfo @@ -36,56 +36,24 @@ ATTR_HEARTBEAT = "heartbeat" MYSENSORS_PLATFORM_DEVICES = "mysensors_devices_{}" -class MySensorsDevice(ABC): +class MySensorNodeEntity(Entity): """Representation of a MySensors device.""" hass: HomeAssistant def __init__( - self, - gateway_id: GatewayId, - gateway: BaseAsyncGateway, - node_id: int, - child_id: int, - value_type: int, + self, gateway_id: GatewayId, gateway: BaseAsyncGateway, node_id: int ) -> None: - """Set up the MySensors device.""" + """Set up the MySensors node entity.""" self.gateway_id: GatewayId = gateway_id self.gateway: BaseAsyncGateway = gateway self.node_id: int = node_id - self.child_id: int = child_id - # value_type as int. string variant can be looked up in gateway consts - self.value_type: int = value_type - self.child_type = self._child.type - self._values: dict[int, Any] = {} self._debouncer: Debouncer | None = None - @property - def dev_id(self) -> DevId: - """Return the DevId of this device. - - It is used to route incoming MySensors messages to the correct device/entity. - """ - return self.gateway_id, self.node_id, self.child_id, self.value_type - - async def async_will_remove_from_hass(self) -> None: - """Remove this entity from home assistant.""" - for platform in PLATFORM_TYPES: - platform_str = MYSENSORS_PLATFORM_DEVICES.format(platform) - if platform_str in self.hass.data[DOMAIN]: - platform_dict = self.hass.data[DOMAIN][platform_str] - if self.dev_id in platform_dict: - del platform_dict[self.dev_id] - _LOGGER.debug("Deleted %s from platform %s", self.dev_id, platform) - @property def _node(self) -> Sensor: return self.gateway.sensors[self.node_id] - @property - def _child(self) -> ChildSensor: - return self._node.children[self.child_id] - @property def sketch_name(self) -> str: """Return the name of the sketch running on the whole node. @@ -110,11 +78,6 @@ class MySensorsDevice(ABC): """ return f"{self.sketch_name} {self.node_id}" - @property - def unique_id(self) -> str: - """Return a unique ID for use in home assistant.""" - return f"{self.gateway_id}-{self.node_id}-{self.child_id}-{self.value_type}" - @property def device_info(self) -> DeviceInfo: """Return the device info.""" @@ -125,6 +88,96 @@ class MySensorsDevice(ABC): sw_version=self.sketch_version, ) + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return device specific attributes.""" + node = self.gateway.sensors[self.node_id] + + return { + ATTR_HEARTBEAT: node.heartbeat, + ATTR_NODE_ID: self.node_id, + } + + @callback + @abstractmethod + def _async_update_callback(self) -> None: + """Update the device.""" + + async def async_update_callback(self) -> None: + """Update the device after delay.""" + if not self._debouncer: + self._debouncer = Debouncer( + self.hass, + _LOGGER, + cooldown=UPDATE_DELAY, + immediate=False, + function=self._async_update_callback, + ) + + await self._debouncer.async_call() + + async def async_added_to_hass(self) -> None: + """Register update callback.""" + self.async_on_remove( + async_dispatcher_connect( + self.hass, + NODE_CALLBACK.format(self.gateway_id, self.node_id), + self.async_update_callback, + ) + ) + self._async_update_callback() + + +def get_mysensors_devices( + hass: HomeAssistant, domain: Platform +) -> dict[DevId, MySensorsChildEntity]: + """Return MySensors devices for a hass platform name.""" + if MYSENSORS_PLATFORM_DEVICES.format(domain) not in hass.data[DOMAIN]: + hass.data[DOMAIN][MYSENSORS_PLATFORM_DEVICES.format(domain)] = {} + devices: dict[DevId, MySensorsChildEntity] = hass.data[DOMAIN][ + MYSENSORS_PLATFORM_DEVICES.format(domain) + ] + return devices + + +class MySensorsChildEntity(MySensorNodeEntity): + """Representation of a MySensors entity.""" + + _attr_should_poll = False + + def __init__( + self, + gateway_id: GatewayId, + gateway: BaseAsyncGateway, + node_id: int, + child_id: int, + value_type: int, + ) -> None: + """Set up the MySensors child entity.""" + super().__init__(gateway_id, gateway, node_id) + self.child_id: int = child_id + # value_type as int. string variant can be looked up in gateway consts + self.value_type: int = value_type + self.child_type = self._child.type + self._values: dict[int, Any] = {} + + @property + def dev_id(self) -> DevId: + """Return the DevId of this device. + + It is used to route incoming MySensors messages to the correct device/entity. + """ + return self.gateway_id, self.node_id, self.child_id, self.value_type + + @property + def _child(self) -> ChildSensor: + return self._node.children[self.child_id] + + @property + def unique_id(self) -> str: + """Return a unique ID for use in home assistant.""" + return f"{self.gateway_id}-{self.node_id}-{self.child_id}-{self.value_type}" + @property def name(self) -> str: """Return the name of this entity.""" @@ -134,21 +187,33 @@ class MySensorsDevice(ABC): return str(child.description) return f"{self.node_name} {self.child_id}" + async def async_will_remove_from_hass(self) -> None: + """Remove this entity from home assistant.""" + for platform in PLATFORM_TYPES: + platform_str = MYSENSORS_PLATFORM_DEVICES.format(platform) + if platform_str in self.hass.data[DOMAIN]: + platform_dict = self.hass.data[DOMAIN][platform_str] + if self.dev_id in platform_dict: + del platform_dict[self.dev_id] + _LOGGER.debug("Deleted %s from platform %s", self.dev_id, platform) + @property - def _extra_attributes(self) -> dict[str, Any]: - """Return device specific attributes.""" - node = self.gateway.sensors[self.node_id] - child = node.children[self.child_id] - attr = { - ATTR_BATTERY_LEVEL: node.battery_level, - ATTR_HEARTBEAT: node.heartbeat, - ATTR_CHILD_ID: self.child_id, - ATTR_DESCRIPTION: child.description, - ATTR_NODE_ID: self.node_id, - } + def available(self) -> bool: + """Return true if entity is available.""" + return self.value_type in self._values + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return entity and device specific state attributes.""" + attr = super().extra_state_attributes + + assert self.platform.config_entry + attr[ATTR_DEVICE] = self.platform.config_entry.data[CONF_DEVICE] + + attr[ATTR_CHILD_ID] = self.child_id + attr[ATTR_DESCRIPTION] = self._child.description set_req = self.gateway.const.SetReq - for value_type, value in self._values.items(): attr[set_req(value_type).name] = value @@ -157,10 +222,8 @@ class MySensorsDevice(ABC): @callback def _async_update(self) -> None: """Update the controller with the latest value from a sensor.""" - node = self.gateway.sensors[self.node_id] - child = node.children[self.child_id] set_req = self.gateway.const.SetReq - for value_type, value in child.values.items(): + for value_type, value in self._child.values.items(): _LOGGER.debug( "Entity update: %s: value_type %s, value = %s", self.name, @@ -182,57 +245,6 @@ class MySensorsDevice(ABC): else: self._values[value_type] = value - @callback - @abstractmethod - def _async_update_callback(self) -> None: - """Update the device.""" - - async def async_update_callback(self) -> None: - """Update the device after delay.""" - if not self._debouncer: - self._debouncer = Debouncer( - self.hass, - _LOGGER, - cooldown=UPDATE_DELAY, - immediate=False, - function=self._async_update_callback, - ) - - await self._debouncer.async_call() - - -def get_mysensors_devices( - hass: HomeAssistant, domain: Platform -) -> dict[DevId, MySensorsEntity]: - """Return MySensors devices for a hass platform name.""" - if MYSENSORS_PLATFORM_DEVICES.format(domain) not in hass.data[DOMAIN]: - hass.data[DOMAIN][MYSENSORS_PLATFORM_DEVICES.format(domain)] = {} - devices: dict[DevId, MySensorsEntity] = hass.data[DOMAIN][ - MYSENSORS_PLATFORM_DEVICES.format(domain) - ] - return devices - - -class MySensorsEntity(MySensorsDevice, Entity): - """Representation of a MySensors entity.""" - - _attr_should_poll = False - - @property - def available(self) -> bool: - """Return true if entity is available.""" - return self.value_type in self._values - - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return entity specific state attributes.""" - attr = self._extra_attributes - - assert self.platform.config_entry - attr[ATTR_DEVICE] = self.platform.config_entry.data[CONF_DEVICE] - - return attr - @callback def _async_update_callback(self) -> None: """Update the entity.""" @@ -241,6 +253,7 @@ class MySensorsEntity(MySensorsDevice, Entity): async def async_added_to_hass(self) -> None: """Register update callback.""" + await super().async_added_to_hass() self.async_on_remove( async_dispatcher_connect( self.hass, @@ -248,11 +261,3 @@ class MySensorsEntity(MySensorsDevice, Entity): self.async_update_callback, ) ) - self.async_on_remove( - async_dispatcher_connect( - self.hass, - NODE_CALLBACK.format(self.gateway_id, self.node_id), - self.async_update_callback, - ) - ) - self._async_update() diff --git a/homeassistant/components/mysensors/device_tracker.py b/homeassistant/components/mysensors/device_tracker.py index 920645a229a..d56e9874560 100644 --- a/homeassistant/components/mysensors/device_tracker.py +++ b/homeassistant/components/mysensors/device_tracker.py @@ -10,7 +10,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import setup_mysensors_platform from .const import MYSENSORS_DISCOVERY, DiscoveryInfo -from .device import MySensorsEntity +from .device import MySensorsChildEntity from .helpers import on_unload @@ -43,7 +43,7 @@ async def async_setup_entry( ) -class MySensorsDeviceTracker(MySensorsEntity, TrackerEntity): +class MySensorsDeviceTracker(MySensorsChildEntity, TrackerEntity): """Represent a MySensors device tracker.""" _latitude: float | None = None diff --git a/homeassistant/components/mysensors/gateway.py b/homeassistant/components/mysensors/gateway.py index ce602e6266d..590ad41d6a2 100644 --- a/homeassistant/components/mysensors/gateway.py +++ b/homeassistant/components/mysensors/gateway.py @@ -42,6 +42,7 @@ from .const import ( ) from .handler import HANDLERS from .helpers import ( + discover_mysensors_node, discover_mysensors_platform, on_unload, validate_child, @@ -244,6 +245,7 @@ async def _discover_persistent_devices( for node_id in gateway.sensors: if not validate_node(gateway, node_id): continue + discover_mysensors_node(hass, entry.entry_id, node_id) node: Sensor = gateway.sensors[node_id] for child in node.children.values(): # child is of type ChildSensor validated = validate_child(entry.entry_id, gateway, node_id, child) diff --git a/homeassistant/components/mysensors/handler.py b/homeassistant/components/mysensors/handler.py index 8a77d167f8b..aa8a235c7cb 100644 --- a/homeassistant/components/mysensors/handler.py +++ b/homeassistant/components/mysensors/handler.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Callable from mysensors import Message +from mysensors.const import SYSTEM_CHILD_ID from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback @@ -12,7 +13,11 @@ from homeassistant.util import decorator from .const import CHILD_CALLBACK, NODE_CALLBACK, DevId, GatewayId from .device import get_mysensors_devices -from .helpers import discover_mysensors_platform, validate_set_msg +from .helpers import ( + discover_mysensors_node, + discover_mysensors_platform, + validate_set_msg, +) HANDLERS: decorator.Registry[ str, Callable[[HomeAssistant, GatewayId, Message], None] @@ -71,6 +76,16 @@ def handle_sketch_version( _handle_node_update(hass, gateway_id, msg) +@HANDLERS.register("presentation") +@callback +def handle_presentation( + hass: HomeAssistant, gateway_id: GatewayId, msg: Message +) -> None: + """Handle an internal presentation message.""" + if msg.child_id == SYSTEM_CHILD_ID: + discover_mysensors_node(hass, gateway_id, msg.node_id) + + @callback def _handle_child_update( hass: HomeAssistant, gateway_id: GatewayId, validated: dict[Platform, list[DevId]] diff --git a/homeassistant/components/mysensors/helpers.py b/homeassistant/components/mysensors/helpers.py index a5f67111738..9985929eecd 100644 --- a/homeassistant/components/mysensors/helpers.py +++ b/homeassistant/components/mysensors/helpers.py @@ -19,9 +19,12 @@ from homeassistant.util.decorator import Registry from .const import ( ATTR_DEVICES, ATTR_GATEWAY_ID, + ATTR_NODE_ID, DOMAIN, FLAT_PLATFORM_TYPES, + MYSENSORS_DISCOVERED_NODES, MYSENSORS_DISCOVERY, + MYSENSORS_NODE_DISCOVERY, MYSENSORS_ON_UNLOAD, TYPE_TO_PLATFORMS, DevId, @@ -65,6 +68,27 @@ def discover_mysensors_platform( ) +@callback +def discover_mysensors_node( + hass: HomeAssistant, gateway_id: GatewayId, node_id: int +) -> None: + """Discover a MySensors node.""" + discovered_nodes = hass.data[DOMAIN].setdefault( + MYSENSORS_DISCOVERED_NODES.format(gateway_id), set() + ) + + if node_id not in discovered_nodes: + discovered_nodes.add(node_id) + async_dispatcher_send( + hass, + MYSENSORS_NODE_DISCOVERY, + { + ATTR_GATEWAY_ID: gateway_id, + ATTR_NODE_ID: node_id, + }, + ) + + def default_schema( gateway: BaseAsyncGateway, child: ChildSensor, value_type_name: ValueType ) -> vol.Schema: diff --git a/homeassistant/components/mysensors/light.py b/homeassistant/components/mysensors/light.py index 213e268696e..7aea1e906a6 100644 --- a/homeassistant/components/mysensors/light.py +++ b/homeassistant/components/mysensors/light.py @@ -19,7 +19,7 @@ from homeassistant.util.color import rgb_hex_to_rgb_list from .. import mysensors from .const import MYSENSORS_DISCOVERY, DiscoveryInfo, SensorType -from .device import MySensorsEntity +from .device import MySensorsChildEntity from .helpers import on_unload @@ -29,7 +29,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up this platform for a specific ConfigEntry(==Gateway).""" - device_class_map: dict[SensorType, type[MySensorsEntity]] = { + device_class_map: dict[SensorType, type[MySensorsChildEntity]] = { "S_DIMMER": MySensorsLightDimmer, "S_RGB_LIGHT": MySensorsLightRGB, "S_RGBW_LIGHT": MySensorsLightRGBW, @@ -56,7 +56,7 @@ async def async_setup_entry( ) -class MySensorsLight(mysensors.device.MySensorsEntity, LightEntity): +class MySensorsLight(mysensors.device.MySensorsChildEntity, LightEntity): """Representation of a MySensors Light child node.""" def __init__(self, *args: Any) -> None: diff --git a/homeassistant/components/mysensors/remote.py b/homeassistant/components/mysensors/remote.py index d72bbfa4235..8521e407ae1 100644 --- a/homeassistant/components/mysensors/remote.py +++ b/homeassistant/components/mysensors/remote.py @@ -17,7 +17,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import setup_mysensors_platform from .const import MYSENSORS_DISCOVERY, DiscoveryInfo -from .device import MySensorsEntity +from .device import MySensorsChildEntity from .helpers import on_unload @@ -50,7 +50,7 @@ async def async_setup_entry( ) -class MySensorsRemote(MySensorsEntity, RemoteEntity): +class MySensorsRemote(MySensorsChildEntity, RemoteEntity): """Representation of a MySensors IR transceiver.""" _current_command: str | None = None diff --git a/homeassistant/components/mysensors/sensor.py b/homeassistant/components/mysensors/sensor.py index 174b1f094b1..84ae1ed031f 100644 --- a/homeassistant/components/mysensors/sensor.py +++ b/homeassistant/components/mysensors/sensor.py @@ -4,6 +4,7 @@ from __future__ import annotations from typing import Any from awesomeversion import AwesomeVersion +from mysensors import BaseAsyncGateway from homeassistant.components.sensor import ( SensorDeviceClass, @@ -30,13 +31,22 @@ from homeassistant.const import ( UnitOfTemperature, UnitOfVolume, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.unit_system import METRIC_SYSTEM from .. import mysensors -from .const import MYSENSORS_DISCOVERY, DiscoveryInfo +from .const import ( + ATTR_GATEWAY_ID, + ATTR_NODE_ID, + DOMAIN, + MYSENSORS_DISCOVERY, + MYSENSORS_GATEWAYS, + MYSENSORS_NODE_DISCOVERY, + DiscoveryInfo, + NodeDiscoveryInfo, +) from .helpers import on_unload SENSORS: dict[str, SensorEntityDescription] = { @@ -211,6 +221,14 @@ async def async_setup_entry( async_add_entities=async_add_entities, ) + @callback + def async_node_discover(discovery_info: NodeDiscoveryInfo) -> None: + """Add battery sensor for each MySensors node.""" + gateway_id = discovery_info[ATTR_GATEWAY_ID] + node_id = discovery_info[ATTR_NODE_ID] + gateway: BaseAsyncGateway = hass.data[DOMAIN][MYSENSORS_GATEWAYS][gateway_id] + async_add_entities([MyBatterySensor(gateway_id, gateway, node_id)]) + on_unload( hass, config_entry.entry_id, @@ -221,8 +239,43 @@ async def async_setup_entry( ), ) + on_unload( + hass, + config_entry.entry_id, + async_dispatcher_connect( + hass, + MYSENSORS_NODE_DISCOVERY, + async_node_discover, + ), + ) -class MySensorsSensor(mysensors.device.MySensorsEntity, SensorEntity): + +class MyBatterySensor(mysensors.device.MySensorNodeEntity, SensorEntity): + """Battery sensor of MySensors node.""" + + _attr_device_class = SensorDeviceClass.BATTERY + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_native_unit_of_measurement = PERCENTAGE + _attr_force_update = True + + @property + def unique_id(self) -> str: + """Return a unique ID for use in home assistant.""" + return f"{self.gateway_id}-{self.node_id}-battery" + + @property + def name(self) -> str: + """Return the name of this entity.""" + return f"{self.node_name} Battery" + + @callback + def _async_update_callback(self) -> None: + """Update the controller with the latest battery level.""" + self._attr_native_value = self._node.battery_level + self.async_write_ha_state() + + +class MySensorsSensor(mysensors.device.MySensorsChildEntity, SensorEntity): """Representation of a MySensors Sensor child node.""" _attr_force_update = True diff --git a/homeassistant/components/mysensors/switch.py b/homeassistant/components/mysensors/switch.py index 6067a98af08..b1ec1a420d2 100644 --- a/homeassistant/components/mysensors/switch.py +++ b/homeassistant/components/mysensors/switch.py @@ -12,7 +12,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import setup_mysensors_platform from .const import MYSENSORS_DISCOVERY, DiscoveryInfo, SensorType -from .device import MySensorsEntity +from .device import MySensorsChildEntity from .helpers import on_unload @@ -58,7 +58,7 @@ async def async_setup_entry( ) -class MySensorsSwitch(MySensorsEntity, SwitchEntity): +class MySensorsSwitch(MySensorsChildEntity, SwitchEntity): """Representation of the value of a MySensors Switch child node.""" @property diff --git a/homeassistant/components/mysensors/text.py b/homeassistant/components/mysensors/text.py index e7bb7add084..68fa2a434d5 100644 --- a/homeassistant/components/mysensors/text.py +++ b/homeassistant/components/mysensors/text.py @@ -10,7 +10,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .. import mysensors from .const import MYSENSORS_DISCOVERY, DiscoveryInfo -from .device import MySensorsEntity +from .device import MySensorsChildEntity from .helpers import on_unload @@ -43,7 +43,7 @@ async def async_setup_entry( ) -class MySensorsText(MySensorsEntity, TextEntity): +class MySensorsText(MySensorsChildEntity, TextEntity): """Representation of the value of a MySensors Text child node.""" _attr_native_max = 25 diff --git a/homeassistant/components/nanoleaf/light.py b/homeassistant/components/nanoleaf/light.py index f0425594763..dc251ac1e5d 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 @@ class NanoleafLight(NanoleafEntity, LightEntity): """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 c1513bb1de6..9ce66a53622 100644 --- a/homeassistant/components/neato/camera.py +++ b/homeassistant/components/neato/camera.py @@ -57,6 +57,7 @@ class NeatoCleaningMap(NeatoEntity, Camera): 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 @@ class NeatoCleaningMap(NeatoEntity, Camera): 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 43072f19693..46ad358c638 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 452f1bc3a9c..3b68ddcf3df 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 @@ class NeatoSensor(NeatoEntity, SensorEntity): 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 a80d05eef23..ae90a8230b2 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 @@ class NeatoConnectedSwitch(NeatoEntity, SwitchEntity): 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 @@ class NeatoConnectedSwitch(NeatoEntity, SwitchEntity): "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 @@ class NeatoConnectedSwitch(NeatoEntity, SwitchEntity): 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 ecc39e515c2..891b090d5d3 100644 --- a/homeassistant/components/neato/vacuum.py +++ b/homeassistant/components/neato/vacuum.py @@ -124,7 +124,6 @@ class NeatoConnectedVacuum(NeatoEntity, StateVacuumEntity): 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 @@ class NeatoConnectedVacuum(NeatoEntity, StateVacuumEntity): 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 @@ class NeatoConnectedVacuum(NeatoEntity, StateVacuumEntity): 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 @@ class NeatoConnectedVacuum(NeatoEntity, StateVacuumEntity): 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 @@ class NeatoConnectedVacuum(NeatoEntity, StateVacuumEntity): @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 @@ class NeatoConnectedVacuum(NeatoEntity, StateVacuumEntity): 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 @@ class NeatoConnectedVacuum(NeatoEntity, StateVacuumEntity): 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 90c4056161e..c943ea922e9 100644 --- a/homeassistant/components/nest/camera.py +++ b/homeassistant/components/nest/camera.py @@ -24,7 +24,6 @@ from homeassistant.components.stream import CONF_EXTRA_PART_WAIT_TIME 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 @@ class NestCamera(Camera): """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 @@ class NestCamera(Camera): 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 1bdb60ee1b4..f269e3e89d6 100644 --- a/homeassistant/components/nest/device_info.py +++ b/homeassistant/components/nest/device_info.py @@ -66,10 +66,7 @@ class NestDeviceInfo: @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) + 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/manifest.json b/homeassistant/components/nest/manifest.json index 54bc44a09b3..bf24fc4a4e9 100644 --- a/homeassistant/components/nest/manifest.json +++ b/homeassistant/components/nest/manifest.json @@ -18,7 +18,7 @@ ], "documentation": "https://www.home-assistant.io/integrations/nest", "iot_class": "cloud_push", - "loggers": ["google_nest_sdm", "nest"], + "loggers": ["google_nest_sdm"], "quality_scale": "platinum", - "requirements": ["google-nest-sdm==2.2.5"] + "requirements": ["google-nest-sdm==3.0.2"] } diff --git a/homeassistant/components/nest/services.yaml b/homeassistant/components/nest/services.yaml deleted file mode 100644 index 5f68bd6a1f2..00000000000 --- 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 2c2def6b7a3..717ce5075f7 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 41bf84c8334..2e4bf9e7d3c 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 @@ class NetatmoCover(NetatmoBase, CoverEntity): """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 522b60749d0..b21286ff05b 100644 --- a/homeassistant/components/netgear/__init__.py +++ b/homeassistant/components/netgear/__init__.py @@ -6,7 +6,7 @@ import logging 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 e45e0582d69..f3283f8d7b5 100644 --- a/homeassistant/components/netgear/button.py +++ b/homeassistant/components/netgear/button.py @@ -15,7 +15,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback 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 @@ class NetgearRouterButtonEntity(NetgearRouterCoordinatorEntity, ButtonEntity): """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/device_tracker.py b/homeassistant/components/netgear/device_tracker.py index ffb33d5ebeb..38ad024a2c4 100644 --- a/homeassistant/components/netgear/device_tracker.py +++ b/homeassistant/components/netgear/device_tracker.py @@ -10,7 +10,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback 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 @@ async def async_setup_entry( 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 @@ class NetgearScannerEntity(NetgearBaseEntity, ScannerEntity): 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 00000000000..45418681db0 --- /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/router.py b/homeassistant/components/netgear/router.py index 2dc86833003..3c3be7fe9fb 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 @@ from homeassistant.const import ( 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 @@ class NetgearRouter: 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 239eca5ff83..6e7771d44cb 100644 --- a/homeassistant/components/netgear/sensor.py +++ b/homeassistant/components/netgear/sensor.py @@ -36,40 +36,41 @@ from .const import ( 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 @@ SENSOR_TRAFFIC_TYPES = [ ), 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 @@ SENSOR_TRAFFIC_TYPES = [ ), 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 @@ SENSOR_TRAFFIC_TYPES = [ ), 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 @@ SENSOR_TRAFFIC_TYPES = [ ), 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 @@ SENSOR_TRAFFIC_TYPES = [ ), 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 @@ SENSOR_TRAFFIC_TYPES = [ ), 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 @@ SENSOR_TRAFFIC_TYPES = [ ), 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 @@ SENSOR_TRAFFIC_TYPES = [ ), 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 @@ SENSOR_TRAFFIC_TYPES = [ ), 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 @@ SENSOR_TRAFFIC_TYPES = [ ), 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 @@ SENSOR_TRAFFIC_TYPES = [ ), 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 @@ SENSOR_TRAFFIC_TYPES = [ ), 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 @@ SENSOR_TRAFFIC_TYPES = [ ), 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 @@ SENSOR_TRAFFIC_TYPES = [ ), 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 @@ SENSOR_TRAFFIC_TYPES = [ ), 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 @@ SENSOR_TRAFFIC_TYPES = [ 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 @@ SENSOR_SPEED_TYPES = [ ), 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 @@ SENSOR_SPEED_TYPES = [ ), 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 @@ SENSOR_SPEED_TYPES = [ 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 @@ SENSOR_UTILIZATION = [ ), 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 @@ SENSOR_UTILIZATION = [ 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 @@ class NetgearSensorEntity(NetgearDeviceEntity, SensorEntity): """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 @@ class NetgearRouterSensorEntity(NetgearRouterCoordinatorEntity, RestoreSensor): """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 15766874bc5..6b4883b8ce3 100644 --- a/homeassistant/components/netgear/strings.json +++ b/homeassistant/components/netgear/strings.json @@ -4,8 +4,8 @@ "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%]" } } @@ -29,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 88a89dd32c9..a4548da16a4 100644 --- a/homeassistant/components/netgear/switch.py +++ b/homeassistant/components/netgear/switch.py @@ -15,7 +15,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback 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 @@ SCAN_INTERVAL = timedelta(seconds=300) 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 @@ ROUTER_SWITCH_TYPES = [ ), 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 @@ ROUTER_SWITCH_TYPES = [ ), 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 @@ ROUTER_SWITCH_TYPES = [ ), 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 @@ ROUTER_SWITCH_TYPES = [ ), 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 @@ ROUTER_SWITCH_TYPES = [ ), 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 @@ ROUTER_SWITCH_TYPES = [ ), 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 @@ class NetgearAllowBlock(NetgearDeviceEntity, SwitchEntity): """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 @@ class NetgearRouterSwitchEntity(NetgearRouterEntity, SwitchEntity): """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 b0e9a26864b..78e11e7c174 100644 --- a/homeassistant/components/netgear/update.py +++ b/homeassistant/components/netgear/update.py @@ -15,7 +15,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback 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 @@ class NetgearUpdateEntity(NetgearRouterCoordinatorEntity, UpdateEntity): ) -> 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 4891af77b28..b582f82b929 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 00000000000..d7149bcc9f4 --- /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 4b8bd1a9294..15eb9b4e245 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 b8f36e10fa1..1582ec25ffe 100644 --- a/homeassistant/components/nextbus/sensor.py +++ b/homeassistant/components/nextbus/sensor.py @@ -12,14 +12,16 @@ from homeassistant.components.sensor import ( 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 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -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()), - ) - 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_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> 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) + """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 + ) + ) + + +async def async_setup_entry( + hass: HomeAssistant, + config: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Load values from configuration and initialize the platform.""" 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) - add_entities([NextBusDepartureSensor(client, agency, route, stop, name)], True) + 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, + ) + + 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 00000000000..4f54ebf1656 --- /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 c753c452546..73b3b400ff4 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 79078811881..1b3bc928985 100644 --- a/homeassistant/components/nibe_heatpump/number.py +++ b/homeassistant/components/nibe_heatpump/number.py @@ -56,7 +56,6 @@ class Number(CoilEntity, NumberEntity): 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 95d96de9764..16a7ef2b1f5 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 795e7b17a16..f60c70cc67c 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/nobo_hub/climate.py b/homeassistant/components/nobo_hub/climate.py index e3cfa04802c..7041d097f3e 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 @@ class NoboZone(ClimateEntity): 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 18b34ea0bea..4daaee10ea6 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 @@ class NuHeatThermostat(CoordinatorEntity, ClimateEntity): 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 @@ class NuHeatThermostat(CoordinatorEntity, ClimateEntity): 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 @@ class NuHeatThermostat(CoordinatorEntity, ClimateEntity): """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 aa3566c5a95..4e0f5059c90 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 39418a48878..00000000000 --- 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 9151a86a9f8..165db8bb704 100644 --- a/homeassistant/components/nut/sensor.py +++ b/homeassistant/components/nut/sensor.py @@ -491,6 +491,33 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { 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 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { 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 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { 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 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { 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 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { 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 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { 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 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { 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 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { 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 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { 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 a07e0ec2f7c..2827911a3aa 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 7c49ca278a7..ecf9d39ae55 100644 --- a/homeassistant/components/nws/sensor.py +++ b/homeassistant/components/nws/sensor.py @@ -23,7 +23,6 @@ from homeassistant.const import ( 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 @@ class NWSSensor(CoordinatorEntity[NwsDataUpdateCoordinator], SensorEntity): """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 @@ class NWSSensor(CoordinatorEntity[NwsDataUpdateCoordinator], SensorEntity): 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 @@ class NWSSensor(CoordinatorEntity[NwsDataUpdateCoordinator], SensorEntity): 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 0f594133f69..9d41e54ccd0 100644 --- a/homeassistant/components/nws/weather.py +++ b/homeassistant/components/nws/weather.py @@ -32,7 +32,6 @@ from homeassistant.const import ( ) 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 @@ class NWSWeather(CoordinatorWeatherEntity): 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 @@ class NWSWeather(CoordinatorWeatherEntity): 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 @@ class NWSWeather(CoordinatorWeatherEntity): 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 @@ class NWSWeather(CoordinatorWeatherEntity): 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 @@ class NWSWeather(CoordinatorWeatherEntity): 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 @@ class NWSWeather(CoordinatorWeatherEntity): 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 @@ class NWSWeather(CoordinatorWeatherEntity): 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 @@ class NWSWeather(CoordinatorWeatherEntity): 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 @@ class NWSWeather(CoordinatorWeatherEntity): """ 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 853f5686831..ca55ea25c40 100644 --- a/homeassistant/components/nx584/binary_sensor.py +++ b/homeassistant/components/nx584/binary_sensor.py @@ -131,9 +131,9 @@ class NX584Watcher(threading.Thread): 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 c3b6aab619b..9d6fafd30c7 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 @@ from .const import ( 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 732ef879762..782ec791eeb 100644 --- a/homeassistant/components/nzbget/config_flow.py +++ b/homeassistant/components/nzbget/config_flow.py @@ -6,28 +6,19 @@ from typing import Any 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 @@ class NZBGetConfigFlow(ConfigFlow, domain=DOMAIN): 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 928487738eb..7838d64c6d7 100644 --- a/homeassistant/components/nzbget/const.py +++ b/homeassistant/components/nzbget/const.py @@ -11,7 +11,6 @@ DATA_UNDO_UPDATE_LISTENER = "undo_update_listener" # 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 7326fa50dd5..dcefe25eae9 100644 --- a/homeassistant/components/nzbget/coordinator.py +++ b/homeassistant/components/nzbget/coordinator.py @@ -11,7 +11,6 @@ from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, CONF_PORT, - CONF_SCAN_INTERVAL, CONF_SSL, CONF_USERNAME, CONF_VERIFY_SSL, @@ -32,7 +31,6 @@ class NZBGetDataUpdateCoordinator(DataUpdateCoordinator): hass: HomeAssistant, *, config: Mapping[str, Any], - options: Mapping[str, Any], ) -> None: """Initialize global NZBGet data updater.""" self.nzbget = NZBGetAPI( @@ -47,13 +45,8 @@ class NZBGetDataUpdateCoordinator(DataUpdateCoordinator): 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 a1faa63bb39..4da9a0b505e 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 e6a2b213873..5d72cae37cf 100644 --- a/homeassistant/components/nzbget/switch.py +++ b/homeassistant/components/nzbget/switch.py @@ -47,7 +47,7 @@ class NZBGetDownloadSwitch(NZBGetEntity, SwitchEntity): 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 @@ class NZBGetDownloadSwitch(NZBGetEntity, SwitchEntity): 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/__init__.py b/homeassistant/components/octoprint/__init__.py index 07b2fa1a15d..5fd2182ca00 100644 --- a/homeassistant/components/octoprint/__init__.py +++ b/homeassistant/components/octoprint/__init__.py @@ -1,15 +1,11 @@ """Support for monitoring OctoPrint 3D printers.""" from __future__ import annotations -from datetime import timedelta import logging -from typing import cast import aiohttp -from pyoctoprintapi import ApiError, OctoprintClient, PrinterOffline -from pyoctoprintapi.exceptions import UnauthorizedException +from pyoctoprintapi import OctoprintClient import voluptuous as vol -from yarl import URL from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( @@ -27,15 +23,12 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import Event, HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryAuthFailed import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.typing import ConfigType -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import slugify as util_slugify -import homeassistant.util.dt as dt_util from .const import DOMAIN +from .coordinator import OctoprintDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -209,74 +202,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -class OctoprintDataUpdateCoordinator(DataUpdateCoordinator): - """Class to manage fetching Octoprint data.""" - - config_entry: ConfigEntry - - def __init__( - self, - hass: HomeAssistant, - octoprint: OctoprintClient, - config_entry: ConfigEntry, - interval: int, - ) -> None: - """Initialize.""" - super().__init__( - hass, - _LOGGER, - name=f"octoprint-{config_entry.entry_id}", - update_interval=timedelta(seconds=interval), - ) - self.config_entry = config_entry - self._octoprint = octoprint - self._printer_offline = False - self.data = {"printer": None, "job": None, "last_read_time": None} - - async def _async_update_data(self): - """Update data via API.""" - printer = None - try: - job = await self._octoprint.get_job_info() - except UnauthorizedException as err: - raise ConfigEntryAuthFailed from err - except ApiError as err: - raise UpdateFailed(err) from err - - # If octoprint is on, but the printer is disconnected - # printer will return a 409, so continue using the last - # reading if there is one - try: - printer = await self._octoprint.get_printer_info() - except PrinterOffline: - if not self._printer_offline: - _LOGGER.debug("Unable to retrieve printer information: Printer offline") - self._printer_offline = True - except UnauthorizedException as err: - raise ConfigEntryAuthFailed from err - except ApiError as err: - raise UpdateFailed(err) from err - else: - self._printer_offline = False - - return {"job": job, "printer": printer, "last_read_time": dt_util.utcnow()} - - @property - def device_info(self) -> DeviceInfo: - """Device info.""" - unique_id = cast(str, self.config_entry.unique_id) - configuration_url = URL.build( - scheme=self.config_entry.data[CONF_SSL] and "https" or "http", - host=self.config_entry.data[CONF_HOST], - port=self.config_entry.data[CONF_PORT], - path=self.config_entry.data[CONF_PATH], - ) - - return DeviceInfo( - identifiers={(DOMAIN, unique_id)}, - manufacturer="OctoPrint", - name="OctoPrint", - configuration_url=str(configuration_url), - ) diff --git a/homeassistant/components/octoprint/binary_sensor.py b/homeassistant/components/octoprint/binary_sensor.py index b0e43bd74e0..0bc13f66415 100644 --- a/homeassistant/components/octoprint/binary_sensor.py +++ b/homeassistant/components/octoprint/binary_sensor.py @@ -52,11 +52,7 @@ class OctoPrintBinarySensorBase( 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 578554da5bd..b2c1672b3e4 100644 --- a/homeassistant/components/octoprint/button.py +++ b/homeassistant/components/octoprint/button.py @@ -5,7 +5,6 @@ from homeassistant.components.button import ButtonEntity 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 @@ class OctoprintButton(CoordinatorEntity[OctoprintDataUpdateCoordinator], ButtonE 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/coordinator.py b/homeassistant/components/octoprint/coordinator.py new file mode 100644 index 00000000000..c6ce8fa66b7 --- /dev/null +++ b/homeassistant/components/octoprint/coordinator.py @@ -0,0 +1,93 @@ +"""The data update coordinator for OctoPrint.""" +from __future__ import annotations + +from datetime import timedelta +import logging +from typing import cast + +from pyoctoprintapi import ApiError, OctoprintClient, PrinterOffline +from pyoctoprintapi.exceptions import UnauthorizedException +from yarl import URL + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PATH, CONF_PORT, CONF_SSL +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +import homeassistant.util.dt as dt_util + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class OctoprintDataUpdateCoordinator(DataUpdateCoordinator): + """Class to manage fetching Octoprint data.""" + + config_entry: ConfigEntry + + def __init__( + self, + hass: HomeAssistant, + octoprint: OctoprintClient, + config_entry: ConfigEntry, + interval: int, + ) -> None: + """Initialize.""" + super().__init__( + hass, + _LOGGER, + name=f"octoprint-{config_entry.entry_id}", + update_interval=timedelta(seconds=interval), + ) + self.config_entry = config_entry + self._octoprint = octoprint + self._printer_offline = False + self.data = {"printer": None, "job": None, "last_read_time": None} + + async def _async_update_data(self): + """Update data via API.""" + printer = None + try: + job = await self._octoprint.get_job_info() + except UnauthorizedException as err: + raise ConfigEntryAuthFailed from err + except ApiError as err: + raise UpdateFailed(err) from err + + # If octoprint is on, but the printer is disconnected + # printer will return a 409, so continue using the last + # reading if there is one + try: + printer = await self._octoprint.get_printer_info() + except PrinterOffline: + if not self._printer_offline: + _LOGGER.debug("Unable to retrieve printer information: Printer offline") + self._printer_offline = True + except UnauthorizedException as err: + raise ConfigEntryAuthFailed from err + except ApiError as err: + raise UpdateFailed(err) from err + else: + self._printer_offline = False + + return {"job": job, "printer": printer, "last_read_time": dt_util.utcnow()} + + @property + def device_info(self) -> DeviceInfo: + """Device info.""" + unique_id = cast(str, self.config_entry.unique_id) + configuration_url = URL.build( + scheme=self.config_entry.data[CONF_SSL] and "https" or "http", + host=self.config_entry.data[CONF_HOST], + port=self.config_entry.data[CONF_PORT], + path=self.config_entry.data[CONF_PATH], + ) + + return DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + manufacturer="OctoPrint", + name="OctoPrint", + configuration_url=str(configuration_url), + ) diff --git a/homeassistant/components/octoprint/sensor.py b/homeassistant/components/octoprint/sensor.py index 17bea7b8ac5..1ea29c2b4e8 100644 --- a/homeassistant/components/octoprint/sensor.py +++ b/homeassistant/components/octoprint/sensor.py @@ -104,11 +104,7 @@ class OctoPrintSensorBase( 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 23cdf6ce56e..c6dbfe6f9c4 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 5cb7605b854..be082584308 100644 --- a/homeassistant/components/omnilogic/sensor.py +++ b/homeassistant/components/omnilogic/sensor.py @@ -66,7 +66,7 @@ class OmnilogicSensor(OmniLogicEntity, SensorEntity): coordinator: OmniLogicUpdateCoordinator, kind: str, name: str, - device_class: str, + device_class: SensorDeviceClass | None, icon: str, unit: str, item_id: tuple, @@ -85,20 +85,10 @@ class OmnilogicSensor(OmniLogicEntity, SensorEntity): 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 @@ class OmniLogicTemperatureSensor(OmnilogicSensor): 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 @@ class OmniLogicPumpSpeedSensor(OmnilogicSensor): 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 @@ class OmniLogicSaltLevelSensor(OmnilogicSensor): """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 @@ class OmniLogicORPSensor(OmnilogicSensor): 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 4345f3498fd..90c79003b8a 100644 --- a/homeassistant/components/ondilo_ico/sensor.py +++ b/homeassistant/components/ondilo_ico/sensor.py @@ -153,7 +153,13 @@ class OndiloICO( 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 @@ class OndiloICO( 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 96ce70344fd..013dd2e453f 100644 --- a/homeassistant/components/onvif/camera.py +++ b/homeassistant/components/onvif/camera.py @@ -113,29 +113,20 @@ class ONVIFCameraEntity(ONVIFBaseEntity, Camera): ) 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 bb42e63c52e..603957a230e 100644 --- a/homeassistant/components/onvif/event.py +++ b/homeassistant/components/onvif/event.py @@ -142,7 +142,6 @@ class EventManager: 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 @@ class EventManager: # # 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 8e6e3e25861..6185adb70a1 100644 --- a/homeassistant/components/onvif/parsers.py +++ b/homeassistant/components/onvif/parsers.py @@ -42,21 +42,22 @@ 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 + message_value = msg.Message._value_1 # pylint: disable=protected-access + topic_value = msg.Topic._value_1 # pylint: disable=protected-access + source = message_value.Source.SimpleItem[0].Value return Event( - f"{uid}_{msg.Topic._value_1}_{source}", + f"{uid}_{topic_value}_{source}", "Motion Alarm", "binary_sensor", "motion", None, - msg.Message._value_1.Data.SimpleItem[0].Value == "true", + message_value.Data.SimpleItem[0].Value == "true", ) except (AttributeError, KeyError): return None @@ -65,21 +66,22 @@ 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 + message_value = msg.Message._value_1 # pylint: disable=protected-access + topic_value = msg.Topic._value_1 # pylint: disable=protected-access + source = message_value.Source.SimpleItem[0].Value return Event( - f"{uid}_{msg.Topic._value_1}_{source}", + f"{uid}_{topic_value}_{source}", "Image Too Blurry", "binary_sensor", "problem", None, - msg.Message._value_1.Data.SimpleItem[0].Value == "true", + message_value.Data.SimpleItem[0].Value == "true", EntityCategory.DIAGNOSTIC, ) except (AttributeError, KeyError): @@ -89,21 +91,22 @@ 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 + message_value = msg.Message._value_1 # pylint: disable=protected-access + topic_value = msg.Topic._value_1 # pylint: disable=protected-access + source = message_value.Source.SimpleItem[0].Value return Event( - f"{uid}_{msg.Topic._value_1}_{source}", + f"{uid}_{topic_value}_{source}", "Image Too Dark", "binary_sensor", "problem", None, - msg.Message._value_1.Data.SimpleItem[0].Value == "true", + message_value.Data.SimpleItem[0].Value == "true", EntityCategory.DIAGNOSTIC, ) except (AttributeError, KeyError): @@ -113,21 +116,22 @@ 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 + message_value = msg.Message._value_1 # pylint: disable=protected-access + topic_value = msg.Topic._value_1 # pylint: disable=protected-access + source = message_value.Source.SimpleItem[0].Value return Event( - f"{uid}_{msg.Topic._value_1}_{source}", + f"{uid}_{topic_value}_{source}", "Image Too Bright", "binary_sensor", "problem", None, - msg.Message._value_1.Data.SimpleItem[0].Value == "true", + message_value.Data.SimpleItem[0].Value == "true", EntityCategory.DIAGNOSTIC, ) except (AttributeError, KeyError): @@ -137,28 +141,28 @@ 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 + message_value = msg.Message._value_1 # pylint: disable=protected-access + topic_value = msg.Topic._value_1 # pylint: disable=protected-access + source = message_value.Source.SimpleItem[0].Value return Event( - f"{uid}_{msg.Topic._value_1}_{source}", + f"{uid}_{topic_value}_{source}", "Global Scene Change", "binary_sensor", "problem", None, - msg.Message._value_1.Data.SimpleItem[0].Value == "true", + message_value.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 +172,9 @@ 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: + message_value = msg.Message._value_1 # pylint: disable=protected-access + topic_value = msg.Topic._value_1 # pylint: disable=protected-access + for source in message_value.Source.SimpleItem: if source.Name == "AudioSourceConfigurationToken": audio_source = source.Value if source.Name == "AudioAnalyticsConfigurationToken": @@ -177,19 +183,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}_{topic_value}_{audio_source}_{audio_analytics}_{rule}", "Detected Sound", "binary_sensor", "sound", None, - msg.Message._value_1.Data.SimpleItem[0].Value == "true", + message_value.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 +204,9 @@ 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: + message_value = msg.Message._value_1 # pylint: disable=protected-access + topic_value = msg.Topic._value_1 # pylint: disable=protected-access + for source in message_value.Source.SimpleItem: if source.Name == "VideoSourceConfigurationToken": video_source = _normalize_video_source(source.Value) if source.Name == "VideoAnalyticsConfigurationToken": @@ -208,12 +215,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}_{topic_value}_{video_source}_{video_analytics}_{rule}", "Field Detection", "binary_sensor", "motion", None, - msg.Message._value_1.Data.SimpleItem[0].Value == "true", + message_value.Data.SimpleItem[0].Value == "true", ) return evt except (AttributeError, KeyError): @@ -221,7 +228,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 +237,9 @@ 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: + message_value = msg.Message._value_1 # pylint: disable=protected-access + topic_value = msg.Topic._value_1 # pylint: disable=protected-access + for source in message_value.Source.SimpleItem: if source.Name == "VideoSourceConfigurationToken": video_source = _normalize_video_source(source.Value) if source.Name == "VideoAnalyticsConfigurationToken": @@ -240,19 +248,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}_{topic_value}_{video_source}_{video_analytics}_{rule}", "Cell Motion Detection", "binary_sensor", "motion", None, - msg.Message._value_1.Data.SimpleItem[0].Value == "true", + message_value.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 +269,9 @@ 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: + message_value = msg.Message._value_1 # pylint: disable=protected-access + topic_value = msg.Topic._value_1 # pylint: disable=protected-access + for source in message_value.Source.SimpleItem: if source.Name == "VideoSourceConfigurationToken": video_source = _normalize_video_source(source.Value) if source.Name == "VideoAnalyticsConfigurationToken": @@ -271,19 +280,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}_{topic_value}_{video_source}_{video_analytics}_{rule}", "Motion Region Detection", "binary_sensor", "motion", None, - msg.Message._value_1.Data.SimpleItem[0].Value in ["1", "true"], + message_value.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 +301,9 @@ 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: + message_value = msg.Message._value_1 # pylint: disable=protected-access + topic_value = msg.Topic._value_1 # pylint: disable=protected-access + for source in message_value.Source.SimpleItem: if source.Name == "VideoSourceConfigurationToken": video_source = _normalize_video_source(source.Value) if source.Name == "VideoAnalyticsConfigurationToken": @@ -302,12 +312,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}_{topic_value}_{video_source}_{video_analytics}_{rule}", "Tamper Detection", "binary_sensor", "problem", None, - msg.Message._value_1.Data.SimpleItem[0].Value == "true", + message_value.Data.SimpleItem[0].Value == "true", EntityCategory.DIAGNOSTIC, ) except (AttributeError, KeyError): @@ -315,7 +325,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 +332,25 @@ async def async_parse_dog_cat_detector(uid: str, msg) -> Event | None: """ try: video_source = "" - for source in msg.Message._value_1.Source.SimpleItem: + message_value = msg.Message._value_1 # pylint: disable=protected-access + topic_value = msg.Topic._value_1 # pylint: disable=protected-access + for source in message_value.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}_{topic_value}_{video_source}", "Pet Detection", "binary_sensor", "motion", None, - msg.Message._value_1.Data.SimpleItem[0].Value == "true", + message_value.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 +358,25 @@ async def async_parse_vehicle_detector(uid: str, msg) -> Event | None: """ try: video_source = "" - for source in msg.Message._value_1.Source.SimpleItem: + message_value = msg.Message._value_1 # pylint: disable=protected-access + topic_value = msg.Topic._value_1 # pylint: disable=protected-access + for source in message_value.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}_{topic_value}_{video_source}", "Vehicle Detection", "binary_sensor", "motion", None, - msg.Message._value_1.Data.SimpleItem[0].Value == "true", + message_value.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 +384,25 @@ async def async_parse_person_detector(uid: str, msg) -> Event | None: """ try: video_source = "" - for source in msg.Message._value_1.Source.SimpleItem: + message_value = msg.Message._value_1 # pylint: disable=protected-access + topic_value = msg.Topic._value_1 # pylint: disable=protected-access + for source in message_value.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}_{topic_value}_{video_source}", "Person Detection", "binary_sensor", "motion", None, - msg.Message._value_1.Data.SimpleItem[0].Value == "true", + message_value.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 +410,25 @@ async def async_parse_face_detector(uid: str, msg) -> Event | None: """ try: video_source = "" - for source in msg.Message._value_1.Source.SimpleItem: + message_value = msg.Message._value_1 # pylint: disable=protected-access + topic_value = msg.Topic._value_1 # pylint: disable=protected-access + for source in message_value.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}_{topic_value}_{video_source}", "Face Detection", "binary_sensor", "motion", None, - msg.Message._value_1.Data.SimpleItem[0].Value == "true", + message_value.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 +436,85 @@ async def async_parse_visitor_detector(uid: str, msg) -> Event | None: """ try: video_source = "" - for source in msg.Message._value_1.Source.SimpleItem: + message_value = msg.Message._value_1 # pylint: disable=protected-access + topic_value = msg.Topic._value_1 # pylint: disable=protected-access + for source in message_value.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}_{topic_value}_{video_source}", "Visitor Detection", "binary_sensor", "occupancy", None, - msg.Message._value_1.Data.SimpleItem[0].Value == "true", + message_value.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 + message_value = msg.Message._value_1 # pylint: disable=protected-access + topic_value = msg.Topic._value_1 # pylint: disable=protected-access + source = message_value.Source.SimpleItem[0].Value return Event( - f"{uid}_{msg.Topic._value_1}_{source}", + f"{uid}_{topic_value}_{source}", "Digital Input", "binary_sensor", None, None, - msg.Message._value_1.Data.SimpleItem[0].Value == "true", + message_value.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 + message_value = msg.Message._value_1 # pylint: disable=protected-access + topic_value = msg.Topic._value_1 # pylint: disable=protected-access + source = message_value.Source.SimpleItem[0].Value return Event( - f"{uid}_{msg.Topic._value_1}_{source}", + f"{uid}_{topic_value}_{source}", "Relay Triggered", "binary_sensor", None, None, - msg.Message._value_1.Data.SimpleItem[0].Value == "active", + message_value.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 + message_value = msg.Message._value_1 # pylint: disable=protected-access + topic_value = msg.Topic._value_1 # pylint: disable=protected-access + source = message_value.Source.SimpleItem[0].Value return Event( - f"{uid}_{msg.Topic._value_1}_{source}", + f"{uid}_{topic_value}_{source}", "Storage Failure", "binary_sensor", "problem", None, - msg.Message._value_1.Data.SimpleItem[0].Value == "true", + message_value.Data.SimpleItem[0].Value == "true", EntityCategory.DIAGNOSTIC, ) except (AttributeError, KeyError): @@ -504,19 +522,20 @@ 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) + message_value = msg.Message._value_1 # pylint: disable=protected-access + topic_value = msg.Topic._value_1 # pylint: disable=protected-access + usage = float(message_value.Data.SimpleItem[0].Value) if usage <= 1: usage *= 100 return Event( - f"{uid}_{msg.Topic._value_1}", + f"{uid}_{topic_value}", "Processor Usage", "sensor", None, @@ -529,18 +548,17 @@ 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 - ) + message_value = msg.Message._value_1 # pylint: disable=protected-access + topic_value = msg.Topic._value_1 # pylint: disable=protected-access + date_time = local_datetime_or_none(message_value.Data.SimpleItem[0].Value) return Event( - f"{uid}_{msg.Topic._value_1}", + f"{uid}_{topic_value}", "Last Reboot", "sensor", "timestamp", @@ -553,18 +571,17 @@ 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 - ) + message_value = msg.Message._value_1 # pylint: disable=protected-access + topic_value = msg.Topic._value_1 # pylint: disable=protected-access + date_time = local_datetime_or_none(message_value.Data.SimpleItem[0].Value) return Event( - f"{uid}_{msg.Topic._value_1}", + f"{uid}_{topic_value}", "Last Reset", "sensor", "timestamp", @@ -578,7 +595,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 +602,11 @@ 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 - ) + message_value = msg.Message._value_1 # pylint: disable=protected-access + topic_value = msg.Topic._value_1 # pylint: disable=protected-access + date_time = local_datetime_or_none(message_value.Data.SimpleItem[0].Value) return Event( - f"{uid}_{msg.Topic._value_1}", + f"{uid}_{topic_value}", "Last Backup", "sensor", "timestamp", @@ -604,18 +620,17 @@ 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 - ) + message_value = msg.Message._value_1 # pylint: disable=protected-access + topic_value = msg.Topic._value_1 # pylint: disable=protected-access + date_time = local_datetime_or_none(message_value.Data.SimpleItem[0].Value) return Event( - f"{uid}_{msg.Topic._value_1}", + f"{uid}_{topic_value}", "Last Clock Synchronization", "sensor", "timestamp", @@ -629,7 +644,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 +651,16 @@ async def async_parse_jobstate(uid: str, msg) -> Event | None: """ try: - source = msg.Message._value_1.Source.SimpleItem[0].Value + message_value = msg.Message._value_1 # pylint: disable=protected-access + topic_value = msg.Topic._value_1 # pylint: disable=protected-access + source = message_value.Source.SimpleItem[0].Value return Event( - f"{uid}_{msg.Topic._value_1}_{source}", + f"{uid}_{topic_value}_{source}", "Recording Job State", "binary_sensor", None, None, - msg.Message._value_1.Data.SimpleItem[0].Value == "Active", + message_value.Data.SimpleItem[0].Value == "Active", EntityCategory.DIAGNOSTIC, ) except (AttributeError, KeyError): @@ -652,7 +668,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 +677,9 @@ 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: + message_value = msg.Message._value_1 # pylint: disable=protected-access + topic_value = msg.Topic._value_1 # pylint: disable=protected-access + for source in message_value.Source.SimpleItem: if source.Name == "VideoSourceConfigurationToken": video_source = source.Value if source.Name == "VideoAnalyticsConfigurationToken": @@ -671,12 +688,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}_{topic_value}_{video_source}_{video_analytics}_{rule}", "Line Detector Crossed", "sensor", None, None, - msg.Message._value_1.Data.SimpleItem[0].Value, + message_value.Data.SimpleItem[0].Value, EntityCategory.DIAGNOSTIC, ) except (AttributeError, KeyError): @@ -684,7 +701,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 +710,9 @@ 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: + message_value = msg.Message._value_1 # pylint: disable=protected-access + topic_value = msg.Topic._value_1 # pylint: disable=protected-access + for source in message_value.Source.SimpleItem: if source.Name == "VideoSourceConfigurationToken": video_source = _normalize_video_source(source.Value) if source.Name == "VideoAnalyticsConfigurationToken": @@ -703,12 +721,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}_{topic_value}_{video_source}_{video_analytics}_{rule}", "Count Aggregation Counter", "sensor", None, None, - msg.Message._value_1.Data.SimpleItem[0].Value, + message_value.Data.SimpleItem[0].Value, EntityCategory.DIAGNOSTIC, ) except (AttributeError, KeyError): diff --git a/homeassistant/components/opencv/manifest.json b/homeassistant/components/opencv/manifest.json index da541974b46..3c484385934 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/openhardwaremonitor/sensor.py b/homeassistant/components/openhardwaremonitor/sensor.py index 70dbbd38fc8..4206bc72c1d 100644 --- a/homeassistant/components/openhardwaremonitor/sensor.py +++ b/homeassistant/components/openhardwaremonitor/sensor.py @@ -79,6 +79,8 @@ class OpenHardwareMonitorDevice(SensorEntity): @property def native_value(self): """Return the state of the device.""" + if self.value == "-": + return None return self.value @property diff --git a/homeassistant/components/openhome/media_player.py b/homeassistant/components/openhome/media_player.py index efc6ab37f21..51d7774a2fb 100644 --- a/homeassistant/components/openhome/media_player.py +++ b/homeassistant/components/openhome/media_player.py @@ -102,26 +102,23 @@ def catch_request_errors() -> ( 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 9013e50030f..691776e4dfd 100644 --- a/homeassistant/components/openhome/update.py +++ b/homeassistant/components/openhome/update.py @@ -54,17 +54,13 @@ class OpenhomeUpdateEntity(UpdateEntity): """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 0b8d4693cb8..cd8b98880d5 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 2501d00c2eb..d6aa5a3b700 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 @@ from .const import ( 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 @@ class OpenThermBinarySensor(BinarySensorEntity): 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 b34239c933a..bcad621eb82 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 @@ class OpenThermClimate(ClimateEntity): 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 @@ class OpenThermClimate(ClimateEntity): @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 @@ class OpenThermClimate(ClimateEntity): ) 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 @@ class OpenThermClimate(ClimateEntity): """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 @@ class OpenThermClimate(ClimateEntity): 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 @@ class OpenThermClimate(ClimateEntity): 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 1532b787740..a6c75c17113 100644 --- a/homeassistant/components/opentherm_gw/const.py +++ b/homeassistant/components/opentherm_gw/const.py @@ -535,106 +535,3 @@ SENSOR_INFO: dict[str, list] = { [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 b219969e71a..09fbb0ef6ee 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 @@ class OpenThermSensor(SensorEntity): 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 @@ class OpenThermSensor(SensorEntity): 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 cb8d1bffceb..4df91cf4e15 100644 --- a/homeassistant/components/openuv/__init__.py +++ b/homeassistant/components/openuv/__init__.py @@ -18,6 +18,7 @@ from homeassistant.const import ( ) 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 @@ class OpenUvEntity(CoordinatorEntity): 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 05e89ea96d4..71fd841d0fc 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.35"] } diff --git a/homeassistant/components/opower/sensor.py b/homeassistant/components/opower/sensor.py index 6be74deaebf..175bef01449 100644 --- a/homeassistant/components/opower/sensor.py +++ b/homeassistant/components/opower/sensor.py @@ -45,6 +45,7 @@ ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = ( 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 6e72dacf5c6..2bc6f73103f 100644 --- a/homeassistant/components/overkiz/cover_entities/vertical_cover.py +++ b/homeassistant/components/overkiz/cover_entities/vertical_cover.py @@ -45,6 +45,17 @@ OVERKIZ_DEVICE_TO_DEVICE_CLASS = { 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 @@ class VerticalCover(OverkizGenericCover): 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/person/__init__.py b/homeassistant/components/person/__init__.py index ea325380e11..49b719a5490 100644 --- a/homeassistant/components/person/__init__.py +++ b/homeassistant/components/person/__init__.py @@ -47,9 +47,6 @@ from homeassistant.helpers import ( ) 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 @@ The following persons point at invalid users: 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_setup(hass: HomeAssistant, config: ConfigType) -> bool: 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 7c0fdf52258..00000000000 --- 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 d4582afa3b2..6e35c27bbfb 100644 --- a/homeassistant/components/picnic/sensor.py +++ b/homeassistant/components/picnic/sensor.py @@ -256,9 +256,15 @@ class PicnicSensor(SensorEntity, CoordinatorEntity): 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 @@ class PicnicSensor(SensorEntity, CoordinatorEntity): 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 a8b7dc51c1e..e8fbaa5d6f1 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 755ff8d2ae7..b7650567c2b 100644 --- a/homeassistant/components/plaato/entity.py +++ b/homeassistant/components/plaato/entity.py @@ -30,7 +30,18 @@ class PlaatoEntity(entity.Entity): 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 @@ class PlaatoEntity(entity.Entity): 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 b43e18e52f6..f3d9a5c3e41 100644 --- a/homeassistant/components/plaato/sensor.py +++ b/homeassistant/components/plaato/sensor.py @@ -72,17 +72,11 @@ async def async_setup_entry( 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 58e0b78560b..985b4ccb4e9 100644 --- a/homeassistant/components/plex/button.py +++ b/homeassistant/components/plex/button.py @@ -38,17 +38,13 @@ class PlexScanClientsButton(ButtonEntity): 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/manifest.json b/homeassistant/components/plex/manifest.json index bc0c54c49bf..6cf94793173 100644 --- a/homeassistant/components/plex/manifest.json +++ b/homeassistant/components/plex/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_push", "loggers": ["plexapi", "plexwebsocket"], "requirements": [ - "PlexAPI==4.13.2", + "PlexAPI==4.15.3", "plexauth==0.0.6", "plexwebsocket==0.0.13" ], diff --git a/homeassistant/components/plex/media_player.py b/homeassistant/components/plex/media_player.py index 23f2895fd51..3e6875f98b9 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 @@ class PlexMediaPlayer(MediaPlayerEntity): 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 a705d11cb41..972cd8d4bc9 100644 --- a/homeassistant/components/plex/sensor.py +++ b/homeassistant/components/plex/sensor.py @@ -129,6 +129,11 @@ class PlexSensor(SensorEntity): 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 @@ class PlexLibrarySectionSensor(SensorEntity): 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 e87e1f0c281..c8c678d6aae 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.32.2"], + "requirements": ["plugwise==0.33.0"], "zeroconf": ["_plugwise._tcp.local."] } diff --git a/homeassistant/components/plugwise/number.py b/homeassistant/components/plugwise/number.py index 6fd3f7f92da..9865aec2242 100644 --- a/homeassistant/components/plugwise/number.py +++ b/homeassistant/components/plugwise/number.py @@ -60,6 +60,16 @@ NUMBER_TYPES = ( 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, + ), ) @@ -104,7 +114,11 @@ class PlugwiseNumberEntity(PlugwiseEntity, NumberEntity): self._attr_mode = NumberMode.BOX self._attr_native_max_value = self.device[description.key]["upper_bound"] self._attr_native_min_value = self.device[description.key]["lower_bound"] - self._attr_native_step = max(self.device[description.key]["resolution"], 0.5) + + native_step = self.device[description.key]["resolution"] + if description.key != "temperature_offset": + native_step = max(native_step, 0.5) + self._attr_native_step = native_step @property def native_value(self) -> float: diff --git a/homeassistant/components/plugwise/strings.json b/homeassistant/components/plugwise/strings.json index 5210f8a6dc0..f85c83819fa 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 2c1f7daa880..9464e66e3a9 100644 --- a/homeassistant/components/plum_lightpad/light.py +++ b/homeassistant/components/plum_lightpad/light.py @@ -73,6 +73,14 @@ class PlumLight(LightEntity): """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 @@ class PlumLight(LightEntity): 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 @@ class GlowRing(LightEntity): """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 @@ class GlowRing(LightEntity): """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 2030483d9cd..130ea116cc1 100644 --- a/homeassistant/components/point/__init__.py +++ b/homeassistant/components/point/__init__.py @@ -264,9 +264,20 @@ class MinutPointEntity(Entity): 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 @@ class MinutPointEntity(Entity): """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 @@ class MinutPointEntity(Entity): ) 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 @@ class MinutPointEntity(Entity): @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 e8db51fd0fc..81101d2da79 100644 --- a/homeassistant/components/point/binary_sensor.py +++ b/homeassistant/components/point/binary_sensor.py @@ -76,6 +76,9 @@ class MinutPointBinarySensor(MinutPointEntity, BinarySensorEntity): 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 @@ class MinutPointBinarySensor(MinutPointEntity, BinarySensorEntity): 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 34571c801a6..462d8270f0a 100644 --- a/homeassistant/components/point/sensor.py +++ b/homeassistant/components/point/sensor.py @@ -98,13 +98,10 @@ class MinutPointSensor(MinutPointEntity, SensorEntity): """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/poolsense/__init__.py b/homeassistant/components/poolsense/__init__.py index 56b7eaaac77..644ecb8cf3d 100644 --- a/homeassistant/components/poolsense/__init__.py +++ b/homeassistant/components/poolsense/__init__.py @@ -1,23 +1,15 @@ """The PoolSense integration.""" -import asyncio -from datetime import timedelta import logging from poolsense import PoolSense -from poolsense.exceptions import PoolSenseError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client -from homeassistant.helpers.entity import EntityDescription -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, - UpdateFailed, -) -from .const import ATTRIBUTION, DOMAIN +from .const import DOMAIN +from .coordinator import PoolSenseDataUpdateCoordinator PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] @@ -57,44 +49,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 PoolSenseEntity(CoordinatorEntity): - """Implements a common class elements representing the PoolSense component.""" - - _attr_attribution = ATTRIBUTION - - def __init__(self, coordinator, email, description: EntityDescription) -> None: - """Initialize poolsense sensor.""" - super().__init__(coordinator) - self.entity_description = description - self._attr_name = f"PoolSense {description.name}" - self._attr_unique_id = f"{email}-{description.key}" - - -class PoolSenseDataUpdateCoordinator(DataUpdateCoordinator): - """Define an object to hold PoolSense data.""" - - def __init__(self, hass, entry): - """Initialize.""" - self.poolsense = PoolSense( - aiohttp_client.async_get_clientsession(hass), - entry.data[CONF_EMAIL], - entry.data[CONF_PASSWORD], - ) - self.hass = hass - self.entry = entry - - super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=timedelta(hours=1)) - - async def _async_update_data(self): - """Update data via library.""" - data = {} - async with asyncio.timeout(10): - try: - data = await self.poolsense.get_poolsense_data() - except PoolSenseError as error: - _LOGGER.error("PoolSense query did not complete") - raise UpdateFailed(error) from error - - return data diff --git a/homeassistant/components/poolsense/binary_sensor.py b/homeassistant/components/poolsense/binary_sensor.py index e206521c3d9..052a205a37b 100644 --- a/homeassistant/components/poolsense/binary_sensor.py +++ b/homeassistant/components/poolsense/binary_sensor.py @@ -11,8 +11,8 @@ from homeassistant.const import CONF_EMAIL from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import PoolSenseEntity from .const import DOMAIN +from .entity import PoolSenseEntity BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( BinarySensorEntityDescription( @@ -48,6 +48,6 @@ class PoolSenseBinarySensor(PoolSenseEntity, BinarySensorEntity): """Representation of PoolSense binary sensors.""" @property - def is_on(self): + def is_on(self) -> bool: """Return true if the binary sensor is on.""" return self.coordinator.data[self.entity_description.key] == "red" diff --git a/homeassistant/components/poolsense/config_flow.py b/homeassistant/components/poolsense/config_flow.py index 6a6708b4045..64685d67035 100644 --- a/homeassistant/components/poolsense/config_flow.py +++ b/homeassistant/components/poolsense/config_flow.py @@ -1,11 +1,13 @@ """Config flow for PoolSense integration.""" import logging +from typing import Any from poolsense import PoolSense import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import aiohttp_client from .const import DOMAIN @@ -21,7 +23,9 @@ class PoolSenseConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize PoolSense config flow.""" - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle the initial step.""" errors = {} diff --git a/homeassistant/components/poolsense/coordinator.py b/homeassistant/components/poolsense/coordinator.py new file mode 100644 index 00000000000..e5e3e6ad1bd --- /dev/null +++ b/homeassistant/components/poolsense/coordinator.py @@ -0,0 +1,45 @@ +"""DataUpdateCoordinator for poolsense integration.""" +import asyncio +from datetime import timedelta +import logging + +from poolsense import PoolSense +from poolsense.exceptions import PoolSenseError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class PoolSenseDataUpdateCoordinator(DataUpdateCoordinator[dict[str, StateType]]): + """Define an object to hold PoolSense data.""" + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + """Initialize.""" + self.poolsense = PoolSense( + aiohttp_client.async_get_clientsession(hass), + entry.data[CONF_EMAIL], + entry.data[CONF_PASSWORD], + ) + self.hass = hass + + super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=timedelta(hours=1)) + + async def _async_update_data(self) -> dict[str, StateType]: + """Update data via library.""" + data = {} + async with asyncio.timeout(10): + try: + data = await self.poolsense.get_poolsense_data() + except PoolSenseError as error: + _LOGGER.error("PoolSense query did not complete") + raise UpdateFailed(error) from error + + return data diff --git a/homeassistant/components/poolsense/entity.py b/homeassistant/components/poolsense/entity.py new file mode 100644 index 00000000000..0eca39cc48d --- /dev/null +++ b/homeassistant/components/poolsense/entity.py @@ -0,0 +1,24 @@ +"""Base entity for poolsense integration.""" +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import ATTRIBUTION +from .coordinator import PoolSenseDataUpdateCoordinator + + +class PoolSenseEntity(CoordinatorEntity[PoolSenseDataUpdateCoordinator]): + """Implements a common class elements representing the PoolSense component.""" + + _attr_attribution = ATTRIBUTION + + def __init__( + self, + coordinator: PoolSenseDataUpdateCoordinator, + email: str, + description: EntityDescription, + ) -> None: + """Initialize poolsense sensor.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_name = f"PoolSense {description.name}" + self._attr_unique_id = f"{email}-{description.key}" diff --git a/homeassistant/components/poolsense/sensor.py b/homeassistant/components/poolsense/sensor.py index fe3535b378f..ed120562374 100644 --- a/homeassistant/components/poolsense/sensor.py +++ b/homeassistant/components/poolsense/sensor.py @@ -15,9 +15,10 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType -from . import PoolSenseEntity from .const import DOMAIN +from .entity import PoolSenseEntity SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( @@ -93,6 +94,6 @@ class PoolSenseSensor(PoolSenseEntity, SensorEntity): """Sensor representing poolsense data.""" @property - def native_value(self): + def native_value(self) -> StateType: """State of the sensor.""" return self.coordinator.data[self.entity_description.key] diff --git a/homeassistant/components/powerwall/strings.json b/homeassistant/components/powerwall/strings.json index dacf63a68dd..8be76dc8716 100644 --- a/homeassistant/components/powerwall/strings.json +++ b/homeassistant/components/powerwall/strings.json @@ -4,7 +4,7 @@ "step": { "user": { "title": "Connect to the Powerwall", - "description": "The default password is printed inside the Backup Gateway for newer models. For older models, the default password is the last five characters of the serial number for Backup Gateway and can be found in the Tesla app.", + "description": "The default password is the last 5 characters of the password printed inside the Backup Gateway for newer models. For older models, the default password is the last five characters of the serial number for Backup Gateway and can be found in the Tesla app.", "data": { "ip_address": "[%key:common::config_flow::data::ip%]", "password": "[%key:common::config_flow::data::password%]" diff --git a/homeassistant/components/private_ble_device/__init__.py b/homeassistant/components/private_ble_device/__init__.py new file mode 100644 index 00000000000..dcb6555bbc9 --- /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 00000000000..4fec68e507e --- /dev/null +++ b/homeassistant/components/private_ble_device/config_flow.py @@ -0,0 +1,77 @@ +"""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" + + +def _parse_irk(irk: str) -> bytes | None: + if irk.startswith("irk:"): + irk = irk[4:] + + if irk.endswith("="): + try: + irk_bytes = bytes(reversed(base64.b64decode(irk))) + except binascii.Error: + # IRK is not valid base64 + return None + else: + try: + irk_bytes = binascii.unhexlify(irk) + except binascii.Error: + # IRK is not correctly hex encoded + return None + + if len(irk_bytes) != 16: + # IRK must be 16 bytes when decoded + return None + + return irk_bytes + + +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 not (irk_bytes := _parse_irk(irk)): + 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 00000000000..086fd06bfd5 --- /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 00000000000..e41c3d02e9e --- /dev/null +++ b/homeassistant/components/private_ble_device/coordinator.py @@ -0,0 +1,246 @@ +"""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): + previous_interval = bluetooth.async_get_learned_advertising_interval( + self.hass, previous_mac + ) or bluetooth.async_get_fallback_availability_interval( + self.hass, previous_mac + ) + if previous_interval: + bluetooth.async_set_fallback_availability_interval( + self.hass, mac, previous_interval + ) + + 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 00000000000..64e23b25ebe --- /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 00000000000..978313e9671 --- /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 00000000000..9900c854657 --- /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.12.0"] +} diff --git a/homeassistant/components/private_ble_device/sensor.py b/homeassistant/components/private_ble_device/sensor.py new file mode 100644 index 00000000000..b332d057ba9 --- /dev/null +++ b/homeassistant/components/private_ble_device/sensor.py @@ -0,0 +1,146 @@ +"""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, + UnitOfTime, +) +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[ + [HomeAssistant, 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, + ), + PrivateDeviceSensorEntityDescription( + key="estimated_broadcast_interval", + translation_key="estimated_broadcast_interval", + icon="mdi:timer-sync-outline", + native_unit_of_measurement=UnitOfTime.SECONDS, + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda hass, service_info: bluetooth.async_get_learned_advertising_interval( + hass, service_info.address + ) + or bluetooth.async_get_fallback_availability_interval( + hass, service_info.address + ) + or bluetooth.FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, + 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.hass, 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 00000000000..9e20a9476ec --- /dev/null +++ b/homeassistant/components/private_ble_device/strings.json @@ -0,0 +1,33 @@ +{ + "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" + }, + "estimated_broadcast_interval": { + "name": "Estimated broadcast interval" + } + } + } +} diff --git a/homeassistant/components/progettihwsw/binary_sensor.py b/homeassistant/components/progettihwsw/binary_sensor.py index e2d1025cc64..ea7a7dce5c3 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 6cad66e1360..d5c91fcea10 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 77cfb6ba4d1..f466e11a1cc 100644 --- a/homeassistant/components/progettihwsw/switch.py +++ b/homeassistant/components/progettihwsw/switch.py @@ -64,7 +64,7 @@ class ProgettihwswSwitch(CoordinatorEntity, SwitchEntity): """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 @@ class ProgettihwswSwitch(CoordinatorEntity, SwitchEntity): 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 1818f308239..c96ed2e4ed3 100644 --- a/homeassistant/components/prometheus/__init__.py +++ b/homeassistant/components/prometheus/__init__.py @@ -671,6 +671,15 @@ class PrometheusMetrics: 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/proxy/manifest.json b/homeassistant/components/proxy/manifest.json index b38bc93567d..b5b25a66342 100644 --- a/homeassistant/components/proxy/manifest.json +++ b/homeassistant/components/proxy/manifest.json @@ -3,5 +3,5 @@ "name": "Camera Proxy", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/proxy", - "requirements": ["Pillow==10.0.0"] + "requirements": ["Pillow==10.0.1"] } diff --git a/homeassistant/components/ps4/strings.json b/homeassistant/components/ps4/strings.json index 644b2d61216..163f2cc9b94 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 3b538f756e0..d086321c088 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/qnap/strings.json b/homeassistant/components/qnap/strings.json index 64b3f22293a..a5fa3c8a897 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/qrcode/manifest.json b/homeassistant/components/qrcode/manifest.json index 2176aa0c91e..f1f40dd8973 100644 --- a/homeassistant/components/qrcode/manifest.json +++ b/homeassistant/components/qrcode/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/qrcode", "iot_class": "calculated", "loggers": ["pyzbar"], - "requirements": ["Pillow==10.0.0", "pyzbar==0.1.7"] + "requirements": ["Pillow==10.0.1", "pyzbar==0.1.7"] } diff --git a/homeassistant/components/rachio/binary_sensor.py b/homeassistant/components/rachio/binary_sensor.py index 029b1bac6e3..652806a2bad 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 @@ class RachioControllerOnlineBinarySensor(RachioControllerBinarySensor): 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 @@ class RachioRainSensor(RachioControllerBinarySensor): 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 0557a2bdb19..bbb08f6d46f 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 @@ class RachioStandbySwitch(RachioSwitch): 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 @@ class RachioStandbySwitch(RachioSwitch): 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 @@ class RachioRainDelay(RachioSwitch): 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 @@ class RachioRainDelay(RachioSwitch): 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 @@ class RachioRainDelay(RachioSwitch): 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 @@ class RachioZone(RachioSwitch): """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 @@ class RachioZone(RachioSwitch): 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 @@ class RachioZone(RachioSwitch): 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 @@ class RachioSchedule(RachioSwitch): 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/__init__.py b/homeassistant/components/rainbird/__init__.py index 2af0cb30f1e..a97af14f449 100644 --- a/homeassistant/components/rainbird/__init__.py +++ b/homeassistant/components/rainbird/__init__.py @@ -10,10 +10,15 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import CONF_SERIAL_NUMBER -from .coordinator import RainbirdUpdateCoordinator +from .coordinator import RainbirdData -PLATFORMS = [Platform.SWITCH, Platform.SENSOR, Platform.BINARY_SENSOR, Platform.NUMBER] +PLATFORMS = [ + Platform.SWITCH, + Platform.SENSOR, + Platform.BINARY_SENSOR, + Platform.NUMBER, + Platform.CALENDAR, +] DOMAIN = "rainbird" @@ -35,16 +40,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: model_info = await controller.get_model_and_version() except RainbirdApiException as err: raise ConfigEntryNotReady from err - coordinator = RainbirdUpdateCoordinator( - hass, - name=entry.title, - controller=controller, - serial_number=entry.data[CONF_SERIAL_NUMBER], - model_info=model_info, - ) - await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + data = RainbirdData(hass, entry, controller, model_info) + await data.coordinator.async_config_entry_first_refresh() + + hass.data[DOMAIN][entry.entry_id] = data await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/rainbird/binary_sensor.py b/homeassistant/components/rainbird/binary_sensor.py index 139a17f5181..3333d8bc4cb 100644 --- a/homeassistant/components/rainbird/binary_sensor.py +++ b/homeassistant/components/rainbird/binary_sensor.py @@ -31,7 +31,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up entry for a Rain Bird binary_sensor.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = hass.data[DOMAIN][config_entry.entry_id].coordinator async_add_entities([RainBirdSensor(coordinator, RAIN_SENSOR_ENTITY_DESCRIPTION)]) @@ -48,8 +48,11 @@ class RainBirdSensor(CoordinatorEntity[RainbirdUpdateCoordinator], BinarySensorE """Initialize the Rain Bird sensor.""" super().__init__(coordinator) self.entity_description = description - self._attr_unique_id = f"{coordinator.serial_number}-{description.key}" - self._attr_device_info = coordinator.device_info + if coordinator.unique_id: + self._attr_unique_id = f"{coordinator.unique_id}-{description.key}" + self._attr_device_info = coordinator.device_info + else: + self._attr_name = f"{coordinator.device_name} Rainsensor" @property def is_on(self) -> bool | None: diff --git a/homeassistant/components/rainbird/calendar.py b/homeassistant/components/rainbird/calendar.py new file mode 100644 index 00000000000..356f7d7cc4e --- /dev/null +++ b/homeassistant/components/rainbird/calendar.py @@ -0,0 +1,123 @@ +"""Rain Bird irrigation calendar.""" + +from __future__ import annotations + +from datetime import datetime +import logging + +from homeassistant.components.calendar import CalendarEntity, CalendarEvent +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 +from homeassistant.util import dt as dt_util + +from .const import DOMAIN +from .coordinator import RainbirdScheduleUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up entry for a Rain Bird irrigation calendar.""" + data = hass.data[DOMAIN][config_entry.entry_id] + if not data.model_info.model_info.max_programs: + return + + async_add_entities( + [ + RainBirdCalendarEntity( + data.schedule_coordinator, + data.coordinator.unique_id, + data.coordinator.device_info, + data.coordinator.device_name, + ) + ] + ) + + +class RainBirdCalendarEntity( + CoordinatorEntity[RainbirdScheduleUpdateCoordinator], CalendarEntity +): + """A calendar event entity.""" + + _attr_has_entity_name = True + _attr_name: str | None = None + _attr_icon = "mdi:sprinkler" + + def __init__( + self, + coordinator: RainbirdScheduleUpdateCoordinator, + unique_id: str | None, + device_info: DeviceInfo | None, + device_name: str, + ) -> None: + """Create the Calendar event device.""" + super().__init__(coordinator) + self._event: CalendarEvent | None = None + if unique_id: + self._attr_unique_id = unique_id + self._attr_device_info = device_info + else: + self._attr_name = device_name + + @property + def event(self) -> CalendarEvent | None: + """Return the next upcoming event.""" + schedule = self.coordinator.data + if not schedule: + return None + cursor = schedule.timeline_tz(dt_util.DEFAULT_TIME_ZONE).active_after( + dt_util.now() + ) + program_event = next(cursor, None) + if not program_event: + return None + return CalendarEvent( + summary=program_event.program_id.name, + start=dt_util.as_local(program_event.start), + end=dt_util.as_local(program_event.end), + rrule=program_event.rrule_str, + ) + + async def async_get_events( + self, hass: HomeAssistant, start_date: datetime, end_date: datetime + ) -> list[CalendarEvent]: + """Get all events in a specific time frame.""" + schedule = self.coordinator.data + if not schedule: + raise HomeAssistantError( + "Unable to get events: No data from controller yet" + ) + cursor = schedule.timeline_tz(start_date.tzinfo).overlapping( + start_date, + end_date, + ) + return [ + CalendarEvent( + summary=program_event.program_id.name, + start=dt_util.as_local(program_event.start), + end=dt_util.as_local(program_event.end), + rrule=program_event.rrule_str, + ) + for program_event in cursor + ] + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + + # We do not ask for an update with async_add_entities() + # because it will update disabled entities. This is started as a + # task to let it sync in the background without blocking startup + self.coordinator.config_entry.async_create_background_task( + self.hass, + self.coordinator.async_request_refresh(), + "rainbird.calendar-refresh", + ) diff --git a/homeassistant/components/rainbird/coordinator.py b/homeassistant/components/rainbird/coordinator.py index d81b942d669..763e50fe5d9 100644 --- a/homeassistant/components/rainbird/coordinator.py +++ b/homeassistant/components/rainbird/coordinator.py @@ -5,6 +5,7 @@ from __future__ import annotations import asyncio from dataclasses import dataclass import datetime +from functools import cached_property import logging from typing import TypeVar @@ -13,8 +14,9 @@ from pyrainbird.async_client import ( RainbirdApiException, RainbirdDeviceBusyException, ) -from pyrainbird.data import ModelAndVersion +from pyrainbird.data import ModelAndVersion, Schedule +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -22,6 +24,9 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import DOMAIN, MANUFACTURER, TIMEOUT_SECONDS UPDATE_INTERVAL = datetime.timedelta(minutes=1) +# The calendar data requires RPCs for each program/zone, and the data rarely +# changes, so we refresh it less often. +CALENDAR_UPDATE_INTERVAL = datetime.timedelta(minutes=15) _LOGGER = logging.getLogger(__name__) @@ -46,19 +51,18 @@ class RainbirdUpdateCoordinator(DataUpdateCoordinator[RainbirdDeviceState]): hass: HomeAssistant, name: str, controller: AsyncRainbirdController, - serial_number: str, + unique_id: str | None, model_info: ModelAndVersion, ) -> None: - """Initialize ZoneStateUpdateCoordinator.""" + """Initialize RainbirdUpdateCoordinator.""" super().__init__( hass, _LOGGER, name=name, - update_method=self._async_update_data, update_interval=UPDATE_INTERVAL, ) self._controller = controller - self._serial_number = serial_number + self._unique_id = unique_id self._zones: set[int] | None = None self._model_info = model_info @@ -68,16 +72,23 @@ class RainbirdUpdateCoordinator(DataUpdateCoordinator[RainbirdDeviceState]): return self._controller @property - def serial_number(self) -> str: - """Return the device serial number.""" - return self._serial_number + def unique_id(self) -> str | None: + """Return the config entry unique id.""" + return self._unique_id @property - def device_info(self) -> DeviceInfo: + def device_name(self) -> str: + """Device name for the rainbird controller.""" + return f"{MANUFACTURER} Controller" + + @property + def device_info(self) -> DeviceInfo | None: """Return information about the device.""" + if not self._unique_id: + return None return DeviceInfo( - name=f"{MANUFACTURER} Controller", - identifiers={(DOMAIN, self._serial_number)}, + name=self.device_name, + identifiers={(DOMAIN, self._unique_id)}, manufacturer=MANUFACTURER, model=self._model_info.model_name, sw_version=f"{self._model_info.major}.{self._model_info.minor}", @@ -109,3 +120,66 @@ class RainbirdUpdateCoordinator(DataUpdateCoordinator[RainbirdDeviceState]): rain=rain, rain_delay=rain_delay, ) + + +class RainbirdScheduleUpdateCoordinator(DataUpdateCoordinator[Schedule]): + """Coordinator for rainbird irrigation schedule calls.""" + + config_entry: ConfigEntry + + def __init__( + self, + hass: HomeAssistant, + name: str, + controller: AsyncRainbirdController, + ) -> None: + """Initialize ZoneStateUpdateCoordinator.""" + super().__init__( + hass, + _LOGGER, + name=name, + update_method=self._async_update_data, + update_interval=CALENDAR_UPDATE_INTERVAL, + ) + self._controller = controller + + async def _async_update_data(self) -> Schedule: + """Fetch data from Rain Bird device.""" + try: + async with asyncio.timeout(TIMEOUT_SECONDS): + return await self._controller.get_schedule() + except RainbirdApiException as err: + raise UpdateFailed(f"Error communicating with Device: {err}") from err + + +@dataclass +class RainbirdData: + """Holder for shared integration data. + + The coordinators are lazy since they may only be used by some platforms when needed. + """ + + hass: HomeAssistant + entry: ConfigEntry + controller: AsyncRainbirdController + model_info: ModelAndVersion + + @cached_property + def coordinator(self) -> RainbirdUpdateCoordinator: + """Return RainbirdUpdateCoordinator.""" + return RainbirdUpdateCoordinator( + self.hass, + name=self.entry.title, + controller=self.controller, + unique_id=self.entry.unique_id, + model_info=self.model_info, + ) + + @cached_property + def schedule_coordinator(self) -> RainbirdScheduleUpdateCoordinator: + """Return RainbirdScheduleUpdateCoordinator.""" + return RainbirdScheduleUpdateCoordinator( + self.hass, + name=f"{self.entry.title} Schedule", + controller=self.controller, + ) diff --git a/homeassistant/components/rainbird/number.py b/homeassistant/components/rainbird/number.py index de049f921dd..1e72fabafcd 100644 --- a/homeassistant/components/rainbird/number.py +++ b/homeassistant/components/rainbird/number.py @@ -28,7 +28,7 @@ async def async_setup_entry( async_add_entities( [ RainDelayNumber( - hass.data[DOMAIN][config_entry.entry_id], + hass.data[DOMAIN][config_entry.entry_id].coordinator, ) ] ) @@ -51,8 +51,11 @@ class RainDelayNumber(CoordinatorEntity[RainbirdUpdateCoordinator], NumberEntity ) -> None: """Initialize the Rain Bird sensor.""" super().__init__(coordinator) - self._attr_unique_id = f"{coordinator.serial_number}-rain-delay" - self._attr_device_info = coordinator.device_info + if coordinator.unique_id: + self._attr_unique_id = f"{coordinator.unique_id}-rain-delay" + self._attr_device_info = coordinator.device_info + else: + self._attr_name = f"{coordinator.device_name} Rain delay" @property def native_value(self) -> float | None: diff --git a/homeassistant/components/rainbird/sensor.py b/homeassistant/components/rainbird/sensor.py index f5cf2390095..d44e7156cb5 100644 --- a/homeassistant/components/rainbird/sensor.py +++ b/homeassistant/components/rainbird/sensor.py @@ -32,7 +32,7 @@ async def async_setup_entry( async_add_entities( [ RainBirdSensor( - hass.data[DOMAIN][config_entry.entry_id], + hass.data[DOMAIN][config_entry.entry_id].coordinator, RAIN_DELAY_ENTITY_DESCRIPTION, ) ] @@ -52,8 +52,13 @@ class RainBirdSensor(CoordinatorEntity[RainbirdUpdateCoordinator], SensorEntity) """Initialize the Rain Bird sensor.""" super().__init__(coordinator) self.entity_description = description - self._attr_unique_id = f"{coordinator.serial_number}-{description.key}" - self._attr_device_info = coordinator.device_info + if coordinator.unique_id: + self._attr_unique_id = f"{coordinator.unique_id}-{description.key}" + self._attr_device_info = coordinator.device_info + else: + self._attr_name = ( + f"{coordinator.device_name} {description.key.capitalize()}" + ) @property def native_value(self) -> StateType: diff --git a/homeassistant/components/rainbird/switch.py b/homeassistant/components/rainbird/switch.py index ac42e00c676..62b3b0e9a8c 100644 --- a/homeassistant/components/rainbird/switch.py +++ b/homeassistant/components/rainbird/switch.py @@ -33,7 +33,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up entry for a Rain Bird irrigation switches.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = hass.data[DOMAIN][config_entry.entry_id].coordinator async_add_entities( RainBirdSwitch( coordinator, @@ -65,21 +65,23 @@ class RainBirdSwitch(CoordinatorEntity[RainbirdUpdateCoordinator], SwitchEntity) """Initialize a Rain Bird Switch Device.""" super().__init__(coordinator) self._zone = zone + if coordinator.unique_id: + self._attr_unique_id = f"{coordinator.unique_id}-{zone}" + device_name = f"{MANUFACTURER} Sprinkler {zone}" if imported_name: self._attr_name = imported_name self._attr_has_entity_name = False else: - self._attr_name = None + self._attr_name = None if coordinator.unique_id else device_name 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( - name=f"{MANUFACTURER} Sprinkler {zone}", - identifiers={(DOMAIN, self._attr_unique_id)}, - manufacturer=MANUFACTURER, - via_device=(DOMAIN, coordinator.serial_number), - ) + if coordinator.unique_id and self._attr_unique_id: + self._attr_device_info = DeviceInfo( + name=device_name, + identifiers={(DOMAIN, self._attr_unique_id)}, + manufacturer=MANUFACTURER, + via_device=(DOMAIN, coordinator.unique_id), + ) @property def extra_state_attributes(self): diff --git a/homeassistant/components/rainforest_eagle/sensor.py b/homeassistant/components/rainforest_eagle/sensor.py index 113cfceb7d6..987142c6390 100644 --- a/homeassistant/components/rainforest_eagle/sensor.py +++ b/homeassistant/components/rainforest_eagle/sensor.py @@ -75,11 +75,13 @@ class EagleSensor(CoordinatorEntity[EagleDataCoordinator], SensorEntity): """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 @@ class EagleSensor(CoordinatorEntity[EagleDataCoordinator], SensorEntity): 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 6333dcc82f4..bdae62c1bd8 100644 --- a/homeassistant/components/rainmachine/sensor.py +++ b/homeassistant/components/rainmachine/sensor.py @@ -141,7 +141,6 @@ SENSOR_DESCRIPTIONS = ( 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 @@ SENSOR_DESCRIPTIONS = ( 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/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index 72d825d9e78..1c00149192f 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -27,9 +27,7 @@ from .const import ( # noqa: F401 DOMAIN, EVENT_RECORDER_5MIN_STATISTICS_GENERATED, EVENT_RECORDER_HOURLY_STATISTICS_GENERATED, - EXCLUDE_ATTRIBUTES, INTEGRATION_PLATFORM_COMPILE_STATISTICS, - INTEGRATION_PLATFORM_EXCLUDE_ATTRIBUTES, INTEGRATION_PLATFORMS_LOAD_IN_RECORDER_THREAD, SQLITE_URL_PREFIX, SupportedDialect, @@ -132,8 +130,6 @@ def is_entity_recorded(hass: HomeAssistant, entity_id: str) -> bool: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the recorder.""" - exclude_attributes_by_domain: dict[str, set[str]] = {} - hass.data[EXCLUDE_ATTRIBUTES] = exclude_attributes_by_domain conf = config[DOMAIN] entity_filter = convert_include_exclude_filter(conf).get_filter() auto_purge = conf[CONF_AUTO_PURGE] @@ -161,7 +157,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: db_retry_wait=db_retry_wait, entity_filter=entity_filter, exclude_event_types=exclude_event_types, - exclude_attributes_by_domain=exclude_attributes_by_domain, ) instance.async_initialize() instance.async_register() @@ -170,17 +165,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: websocket_api.async_setup(hass) entity_registry.async_setup(hass) - await _async_setup_integration_platform( - hass, instance, exclude_attributes_by_domain - ) + await _async_setup_integration_platform(hass, instance) return await instance.async_db_ready async def _async_setup_integration_platform( - hass: HomeAssistant, - instance: Recorder, - exclude_attributes_by_domain: dict[str, set[str]], + hass: HomeAssistant, instance: Recorder ) -> None: """Set up a recorder integration platform.""" @@ -188,15 +179,6 @@ async def _async_setup_integration_platform( hass: HomeAssistant, domain: str, platform: Any ) -> None: """Process a recorder platform.""" - # We need to add this before as soon as the component is loaded - # to ensure by the time the state is recorded that the excluded - # attributes are known. This is safe to modify in the event loop - # since exclude_attributes_by_domain is never iterated over. - if exclude_attributes := getattr( - platform, INTEGRATION_PLATFORM_EXCLUDE_ATTRIBUTES, None - ): - exclude_attributes_by_domain[domain] = exclude_attributes(hass) - # If the platform has a compile_statistics method, we need to # add it to the recorder queue to be processed. if any( diff --git a/homeassistant/components/recorder/const.py b/homeassistant/components/recorder/const.py index 724a9589680..7389cbf8ddf 100644 --- a/homeassistant/components/recorder/const.py +++ b/homeassistant/components/recorder/const.py @@ -40,10 +40,6 @@ ATTR_APPLY_FILTER = "apply_filter" KEEPALIVE_TIME = 30 - -EXCLUDE_ATTRIBUTES = f"{DOMAIN}_exclude_attributes_by_domain" - - STATISTICS_ROWS_SCHEMA_VERSION = 23 CONTEXT_ID_AS_BINARY_SCHEMA_VERSION = 36 EVENT_TYPE_IDS_SCHEMA_VERSION = 37 @@ -51,9 +47,6 @@ STATES_META_SCHEMA_VERSION = 38 LEGACY_STATES_EVENT_ID_INDEX_SCHEMA_VERSION = 28 - -INTEGRATION_PLATFORM_EXCLUDE_ATTRIBUTES = "exclude_attributes" - INTEGRATION_PLATFORM_COMPILE_STATISTICS = "compile_statistics" INTEGRATION_PLATFORM_VALIDATE_STATISTICS = "validate_statistics" INTEGRATION_PLATFORM_LIST_STATISTIC_IDS = "list_statistic_ids" diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index bbaff24ff77..0e926ad2a22 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -177,7 +177,6 @@ class Recorder(threading.Thread): db_retry_wait: int, entity_filter: Callable[[str], bool], exclude_event_types: set[str], - exclude_attributes_by_domain: dict[str, set[str]], ) -> None: """Initialize the recorder.""" threading.Thread.__init__(self, name="Recorder") @@ -187,7 +186,7 @@ class Recorder(threading.Thread): 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 +197,7 @@ class Recorder(threading.Thread): 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() @@ -221,9 +220,7 @@ class Recorder(threading.Thread): self.event_data_manager = EventDataManager(self) self.event_type_manager = EventTypeManager(self) self.states_meta_manager = StatesMetaManager(self) - self.state_attributes_manager = StateAttributesManager( - self, exclude_attributes_by_domain - ) + self.state_attributes_manager = StateAttributesManager(self) self.statistics_meta_manager = StatisticsMetaManager(self) self.event_session: Session | None = None diff --git a/homeassistant/components/recorder/db_schema.py b/homeassistant/components/recorder/db_schema.py index 508874c54e5..17e34af1e11 100644 --- a/homeassistant/components/recorder/db_schema.py +++ b/homeassistant/components/recorder/db_schema.py @@ -39,7 +39,8 @@ from homeassistant.const import ( MAX_LENGTH_STATE_ENTITY_ID, MAX_LENGTH_STATE_STATE, ) -from homeassistant.core import Context, Event, EventOrigin, State, split_entity_id +from homeassistant.core import Context, Event, EventOrigin, State +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,8 +559,7 @@ class StateAttributes(Base): @staticmethod def shared_attrs_bytes_from_event( event: Event, - entity_sources: dict[str, dict[str, str]], - exclude_attrs_by_domain: dict[str, set[str]], + entity_sources: dict[str, EntityInfo], dialect: SupportedDialect | None, ) -> bytes: """Create shared_attrs from a state_changed event.""" @@ -567,14 +567,9 @@ class StateAttributes(Base): # None state means the state was removed from the state machine if state is None: return b"{}" - domain = split_entity_id(state.entity_id)[0] exclude_attrs = set(ALL_DOMAIN_EXCLUDE_ATTRS) - if base_platform_attrs := exclude_attrs_by_domain.get(domain): - exclude_attrs |= base_platform_attrs - if (entity_info := entity_sources.get(state.entity_id)) and ( - 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 191c74ac0d4..2e1b02a8b64 100644 --- a/homeassistant/components/recorder/history/legacy.py +++ b/homeassistant/components/recorder/history/legacy.py @@ -50,7 +50,7 @@ _BASE_STATES = ( 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 63b19cdb3bf..f40797fe38c 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/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 005859b865b..24fb209ae07 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -24,6 +24,7 @@ import voluptuous as vol from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT from homeassistant.core import HomeAssistant, callback, valid_entity_id from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.singleton import singleton from homeassistant.helpers.typing import UNDEFINED, UndefinedType from homeassistant.util import dt as dt_util from homeassistant.util.unit_conversion import ( @@ -141,10 +142,39 @@ STATISTIC_UNIT_TO_UNIT_CONVERTER: dict[str | None, type[BaseUnitConverter]] = { **{unit: VolumeConverter for unit in VolumeConverter.VALID_UNITS}, } +DATA_SHORT_TERM_STATISTICS_RUN_CACHE = "recorder_short_term_statistics_run_cache" + _LOGGER = logging.getLogger(__name__) +@dataclasses.dataclass(slots=True) +class ShortTermStatisticsRunCache: + """Cache for short term statistics runs.""" + + # This is a mapping of metadata_id:id of the last short term + # statistics run for each metadata_id + _latest_id_by_metadata_id: dict[int, int] = dataclasses.field(default_factory=dict) + + def get_latest_ids(self, metadata_ids: set[int]) -> dict[int, int]: + """Return the latest short term statistics ids for the metadata_ids.""" + return { + metadata_id: id_ + for metadata_id, id_ in self._latest_id_by_metadata_id.items() + if metadata_id in metadata_ids + } + + def set_latest_id_for_metadata_id(self, metadata_id: int, id_: int) -> None: + """Cache the latest id for the metadata_id.""" + self._latest_id_by_metadata_id[metadata_id] = id_ + + def set_latest_ids_for_metadata_ids( + self, metadata_id_to_id: dict[int, int] + ) -> None: + """Cache the latest id for the each metadata_id.""" + self._latest_id_by_metadata_id.update(metadata_id_to_id) + + class BaseStatisticsRow(TypedDict, total=False): """A processed row of statistic data.""" @@ -508,6 +538,8 @@ def _compile_statistics( platform_stats.extend(compiled.platform_stats) current_metadata.update(compiled.current_metadata) + new_short_term_stats: list[StatisticsBase] = [] + updated_metadata_ids: set[int] = set() # Insert collected statistics in the database for stats in platform_stats: modified_statistic_id, metadata_id = statistics_meta_manager.update_or_add( @@ -515,12 +547,14 @@ def _compile_statistics( ) if modified_statistic_id is not None: modified_statistic_ids.add(modified_statistic_id) - _insert_statistics( + updated_metadata_ids.add(metadata_id) + if new_stat := _insert_statistics( session, StatisticsShortTerm, metadata_id, stats["stat"], - ) + ): + new_short_term_stats.append(new_stat) if start.minute == 55: # A full hour is ready, summarize it @@ -533,6 +567,23 @@ def _compile_statistics( if start.minute == 55: instance.hass.bus.fire(EVENT_RECORDER_HOURLY_STATISTICS_GENERATED) + if updated_metadata_ids: + # These are always the newest statistics, so we can update + # the run cache without having to check the start_ts. + session.flush() # populate the ids of the new StatisticsShortTerm rows + run_cache = get_short_term_statistics_run_cache(instance.hass) + # metadata_id is typed to allow None, but we know it's not None here + # so we can safely cast it to int. + run_cache.set_latest_ids_for_metadata_ids( + cast( + dict[int, int], + { + new_stat.metadata_id: new_stat.id + for new_stat in new_short_term_stats + }, + ) + ) + return modified_statistic_ids @@ -566,16 +617,19 @@ def _insert_statistics( table: type[StatisticsBase], metadata_id: int, statistic: StatisticData, -) -> None: +) -> StatisticsBase | None: """Insert statistics in the database.""" try: - session.add(table.from_stats(metadata_id, statistic)) + stat = table.from_stats(metadata_id, statistic) + session.add(stat) + return stat except SQLAlchemyError: _LOGGER.exception( "Unexpected exception when inserting statistics %s:%s ", metadata_id, statistic, ) + return None def _update_statistics( @@ -1809,24 +1863,26 @@ def get_last_short_term_statistics( ) -def _latest_short_term_statistics_stmt( - metadata_ids: list[int], +def get_latest_short_term_statistics_by_ids( + session: Session, ids: Iterable[int] +) -> list[Row]: + """Return the latest short term statistics for a list of ids.""" + stmt = _latest_short_term_statistics_by_ids_stmt(ids) + return list( + cast( + Sequence[Row], + execute_stmt_lambda_element(session, stmt, orm_rows=False), + ) + ) + + +def _latest_short_term_statistics_by_ids_stmt( + ids: Iterable[int], ) -> StatementLambdaElement: - """Create the statement for finding the latest short term stat rows.""" + """Create the statement for finding the latest short term stat rows by id.""" return lambda_stmt( - lambda: select(*QUERY_STATISTICS_SHORT_TERM).join( - ( - most_recent_statistic_row := ( - select( - StatisticsShortTerm.metadata_id, - func.max(StatisticsShortTerm.start_ts).label("start_max"), - ) - .where(StatisticsShortTerm.metadata_id.in_(metadata_ids)) - .group_by(StatisticsShortTerm.metadata_id) - ).subquery() - ), - (StatisticsShortTerm.metadata_id == most_recent_statistic_row.c.metadata_id) - & (StatisticsShortTerm.start_ts == most_recent_statistic_row.c.start_max), + lambda: select(*QUERY_STATISTICS_SHORT_TERM).filter( + StatisticsShortTerm.id.in_(ids) ) ) @@ -1846,11 +1902,38 @@ def get_latest_short_term_statistics( ) if not metadata: return {} - metadata_ids = _extract_metadata_and_discard_impossible_columns(metadata, types) - stmt = _latest_short_term_statistics_stmt(metadata_ids) - stats = cast( - Sequence[Row], execute_stmt_lambda_element(session, stmt, orm_rows=False) + metadata_ids = set( + _extract_metadata_and_discard_impossible_columns(metadata, types) ) + run_cache = get_short_term_statistics_run_cache(hass) + # Try to find the latest short term statistics ids for the metadata_ids + # from the run cache first if we have it. If the run cache references + # a non-existent id because of a purge, we will detect it missing in the + # next step and run a query to re-populate the cache. + stats: list[Row] = [] + if metadata_id_to_id := run_cache.get_latest_ids(metadata_ids): + stats = get_latest_short_term_statistics_by_ids( + session, metadata_id_to_id.values() + ) + # If we are missing some metadata_ids in the run cache, we need run a query + # to populate the cache for each metadata_id, and then run another query + # to get the latest short term statistics for the missing metadata_ids. + if (missing_metadata_ids := metadata_ids - set(metadata_id_to_id)) and ( + found_latest_ids := { + latest_id + for metadata_id in missing_metadata_ids + if ( + latest_id := cache_latest_short_term_statistic_id_for_metadata_id( + run_cache, session, metadata_id + ) + ) + is not None + } + ): + stats.extend( + get_latest_short_term_statistics_by_ids(session, found_latest_ids) + ) + if not stats: return {} @@ -2221,9 +2304,77 @@ def _import_statistics_with_session( else: _insert_statistics(session, table, metadata_id, stat) + if table != StatisticsShortTerm: + return True + + # We just inserted new short term statistics, so we need to update the + # ShortTermStatisticsRunCache with the latest id for the metadata_id + run_cache = get_short_term_statistics_run_cache(instance.hass) + cache_latest_short_term_statistic_id_for_metadata_id( + run_cache, session, metadata_id + ) + return True +@singleton(DATA_SHORT_TERM_STATISTICS_RUN_CACHE) +def get_short_term_statistics_run_cache( + hass: HomeAssistant, +) -> ShortTermStatisticsRunCache: + """Get the short term statistics run cache.""" + return ShortTermStatisticsRunCache() + + +def cache_latest_short_term_statistic_id_for_metadata_id( + run_cache: ShortTermStatisticsRunCache, session: Session, metadata_id: int +) -> int | None: + """Cache the latest short term statistic for a given metadata_id. + + Returns the id of the latest short term statistic for the metadata_id + that was added to the cache, or None if no latest short term statistic + was found for the metadata_id. + """ + if latest := cast( + Sequence[Row], + execute_stmt_lambda_element( + session, + _find_latest_short_term_statistic_for_metadata_id_stmt(metadata_id), + orm_rows=False, + ), + ): + id_: int = latest[0].id + run_cache.set_latest_id_for_metadata_id(metadata_id, id_) + return id_ + return None + + +def _find_latest_short_term_statistic_for_metadata_id_stmt( + metadata_id: int, +) -> StatementLambdaElement: + """Create a statement to find the latest short term statistics for a metadata_id.""" + # + # This code only looks up one row, and should not be refactored to + # lookup multiple using func.max + # or similar, as that will cause the query to be significantly slower + # for DBMs such as PostgreSQL that will have to do a full scan + # + # For PostgreSQL a combined query plan looks like: + # (actual time=2.218..893.909 rows=170531 loops=1) + # + # For PostgreSQL a separate query plan looks like: + # (actual time=0.301..0.301 rows=1 loops=1) + # + # + return lambda_stmt( + lambda: select( + StatisticsShortTerm.id, + ) + .where(StatisticsShortTerm.metadata_id == metadata_id) + .order_by(StatisticsShortTerm.start_ts.desc()) + .limit(1) + ) + + @retryable_database_job("statistics") def import_statistics( instance: Recorder, diff --git a/homeassistant/components/recorder/table_managers/state_attributes.py b/homeassistant/components/recorder/table_managers/state_attributes.py index 3ae67b932bf..653ef1689bd 100644 --- a/homeassistant/components/recorder/table_managers/state_attributes.py +++ b/homeassistant/components/recorder/table_managers/state_attributes.py @@ -34,13 +34,10 @@ _LOGGER = logging.getLogger(__name__) class StateAttributesManager(BaseLRUTableManager[StateAttributes]): """Manage the StateAttributes table.""" - def __init__( - self, recorder: Recorder, exclude_attributes_by_domain: dict[str, set[str]] - ) -> None: + def __init__(self, recorder: Recorder) -> None: """Initialize the event type manager.""" super().__init__(recorder, CACHE_SIZE) self.active = True # always active - self._exclude_attributes_by_domain = exclude_attributes_by_domain self._entity_sources = entity_sources(recorder.hass) def serialize_from_event(self, event: Event) -> bytes | None: @@ -49,7 +46,6 @@ class StateAttributesManager(BaseLRUTableManager[StateAttributes]): return StateAttributes.shared_attrs_bytes_from_event( event, self._entity_sources, - self._exclude_attributes_by_domain, self.recorder.dialect_name, ) except JSON_ENCODE_EXCEPTIONS as ex: diff --git a/homeassistant/components/renson/__init__.py b/homeassistant/components/renson/__init__.py index 86dfdc1f18b..2a9c13be543 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.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 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 cad8b92c0c3..39c2b1b883d 100644 --- a/homeassistant/components/renson/binary_sensor.py +++ b/homeassistant/components/renson/binary_sensor.py @@ -25,8 +25,8 @@ from homeassistant.const import EntityCategory 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 00000000000..53d995ba792 --- /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 00000000000..924a3b765f5 --- /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 245b55d6611..9bb2c27b112 100644 --- a/homeassistant/components/renson/entity.py +++ b/homeassistant/components/renson/entity.py @@ -12,8 +12,8 @@ from renson_endura_delta.renson import RensonVentilation 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 00000000000..da6850859a6 --- /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 5ff219cc26c..1a7f367a946 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 00000000000..344fa3ff0bd --- /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 661ab82f373..b729e2969d6 100644 --- a/homeassistant/components/renson/sensor.py +++ b/homeassistant/components/renson/sensor.py @@ -46,8 +46,9 @@ from homeassistant.const import ( 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 = { diff --git a/homeassistant/components/renson/strings.json b/homeassistant/components/renson/strings.json index 20db9e788b8..7099cdf2c45 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 d924f395c50..59fbdc22747 100644 --- a/homeassistant/components/reolink/config_flow.py +++ b/homeassistant/components/reolink/config_flow.py @@ -19,7 +19,7 @@ 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 has_connection_problem +from .util import is_connected _LOGGER = logging.getLogger(__name__) @@ -103,7 +103,7 @@ class ReolinkFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): and CONF_PASSWORD in existing_entry.data and existing_entry.data[CONF_HOST] != discovery_info.ip ): - if has_connection_problem(self.hass, existing_entry): + 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'", @@ -122,7 +122,7 @@ class ReolinkFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): "Reolink DHCP reported new IP '%s', " "but got error '%s' trying to connect, so sticking to IP '%s'", discovery_info.ip, - str(err), + err, existing_entry.data[CONF_HOST], ) raise AbortFlow("already_configured") from err diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index 2487013b032..d470711267d 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -322,7 +322,7 @@ class ReolinkHost: "Reolink error while unsubscribing from host %s:%s: %s", self._api.host, self._api.port, - str(err), + err, ) try: @@ -332,7 +332,7 @@ class ReolinkHost: "Reolink error while logging out for host %s:%s: %s", self._api.host, self._api.port, - str(err), + err, ) async def _async_start_long_polling(self, initial=False): @@ -349,7 +349,7 @@ class ReolinkHost: _LOGGER.error( "Reolink %s event long polling subscription lost: %s", self._api.nvr_name, - str(err), + err, ) except ReolinkError as err: # make sure the long_poll_task is always created to try again later @@ -358,7 +358,7 @@ class ReolinkHost: _LOGGER.error( "Reolink %s event long polling subscription lost: %s", self._api.nvr_name, - str(err), + err, ) else: self._lost_subscription = False @@ -428,7 +428,7 @@ class ReolinkHost: _LOGGER.error( "Reolink %s event subscription lost: %s", self._api.nvr_name, - str(err), + err, ) else: self._lost_subscription = False @@ -568,7 +568,7 @@ class ReolinkHost: "Reolink error while polling motion state for host %s:%s: %s", self._api.host, self._api.port, - str(err), + err, ) finally: # schedule next poll diff --git a/homeassistant/components/reolink/util.py b/homeassistant/components/reolink/util.py index 2ab625647a7..cc9ad192bc3 100644 --- a/homeassistant/components/reolink/util.py +++ b/homeassistant/components/reolink/util.py @@ -8,16 +8,13 @@ from . import ReolinkData from .const import DOMAIN -def has_connection_problem( - hass: HomeAssistant, config_entry: config_entries.ConfigEntry -) -> bool: - """Check if a existing entry has a connection problem.""" +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 ) - connection_problem = ( + return ( reolink_data is not None and config_entry.state == config_entries.ConfigEntryState.LOADED and reolink_data.device_coordinator.last_update_success ) - return connection_problem diff --git a/homeassistant/components/rest/manifest.json b/homeassistant/components/rest/manifest.json index c8796c7161c..d638c20d2a4 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 b96e03e7eb4..fd6db8f0c60 100644 --- a/homeassistant/components/rflink/sensor.py +++ b/homeassistant/components/rflink/sensor.py @@ -352,6 +352,7 @@ class RflinkSensor(RflinkDevice, SensorEntity): """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 0b3f1509b18..7f897d17203 100644 --- a/homeassistant/components/ring/camera.py +++ b/homeassistant/components/ring/camera.py @@ -60,6 +60,7 @@ class RingCam(RingEntityMixin, Camera): 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 @@ class RingCam(RingEntityMixin, Camera): 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 2b345b3b703..7160d2ef725 100644 --- a/homeassistant/components/ring/entity.py +++ b/homeassistant/components/ring/entity.py @@ -19,6 +19,12 @@ class RingEntityMixin(Entity): 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 @@ class RingEntityMixin(Entity): 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 2604e557b79..93640e2764e 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 @@ class RingLight(RingEntityMixin, LightEntity): 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 @@ class RingLight(RingEntityMixin, LightEntity): _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 fbaeb8a4b5b..af23af07eba 100644 --- a/homeassistant/components/ring/sensor.py +++ b/homeassistant/components/ring/sensor.py @@ -68,6 +68,9 @@ class RingSensor(RingEntityMixin, SensorEntity): 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 @@ class HealthDataRingSensor(RingSensor): """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 43bd303577a..7069acd5f0f 100644 --- a/homeassistant/components/ring/switch.py +++ b/homeassistant/components/ring/switch.py @@ -50,24 +50,20 @@ class BaseRingSwitch(RingEntityMixin, SwitchEntity): """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 @@ class SirenSwitch(BaseRingSwitch): 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 @@ class SirenSwitch(BaseRingSwitch): _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 @@ class SirenSwitch(BaseRingSwitch): 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 7f8e3be698b..e522c29ce19 100644 --- a/homeassistant/components/risco/entity.py +++ b/homeassistant/components/risco/entity.py @@ -31,16 +31,10 @@ class RiscoCloudEntity(CoordinatorEntity[RiscoDataUpdateCoordinator]): def _get_data_from_coordinator(self) -> None: raise NotImplementedError - def _refresh_from_coordinator(self) -> None: + def _handle_coordinator_update(self) -> None: self._get_data_from_coordinator() self.async_write_ha_state() - async def async_added_to_hass(self) -> None: - """When entity is added to hass.""" - self.async_on_remove( - self.coordinator.async_add_listener(self._refresh_from_coordinator) - ) - @property def _risco(self): """Return the Risco API object.""" diff --git a/homeassistant/components/risco/sensor.py b/homeassistant/components/risco/sensor.py index bb416b8c550..1d60ea4d7c2 100644 --- a/homeassistant/components/risco/sensor.py +++ b/homeassistant/components/risco/sensor.py @@ -88,12 +88,10 @@ class RiscoSensor(CoordinatorEntity[RiscoEventsDataUpdateCoordinator], SensorEnt async def async_added_to_hass(self) -> None: """When entity is added to hass.""" + await super().async_added_to_hass() self._entity_registry = er.async_get(self.hass) - self.async_on_remove( - self.coordinator.async_add_listener(self._refresh_from_coordinator) - ) - def _refresh_from_coordinator(self): + def _handle_coordinator_update(self): events = self.coordinator.data for event in reversed(events): if event.category_id in self._excludes: diff --git a/homeassistant/components/roborock/binary_sensor.py b/homeassistant/components/roborock/binary_sensor.py new file mode 100644 index 00000000000..320b0fc7c6d --- /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 2fc59134d14..36078e53b3e 100644 --- a/homeassistant/components/roborock/const.py +++ b/homeassistant/components/roborock/const.py @@ -13,4 +13,5 @@ PLATFORMS = [ Platform.SWITCH, Platform.TIME, Platform.NUMBER, + Platform.BINARY_SENSOR, ] diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index dfd5a9ee1c7..6882754f49a 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.34.1"] + "requirements": ["python-roborock==0.34.6"] } diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py index 2d76aac33d3..5cf71bb12f4 100644 --- a/homeassistant/components/roborock/select.py +++ b/homeassistant/components/roborock/select.py @@ -7,6 +7,7 @@ from roborock.roborock_typing import RoborockCommand 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 @@ SELECT_DESCRIPTIONS: list[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 @@ SELECT_DESCRIPTIONS: list[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 0629839f01b..113e02e4abe 100644 --- a/homeassistant/components/roborock/sensor.py +++ b/homeassistant/components/roborock/sensor.py @@ -3,8 +3,14 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass +import datetime -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 ( @@ -33,7 +39,7 @@ from .device import RoborockCoordinatedEntity class RoborockSensorDescriptionMixin: """A class that describes sensor entities.""" - value_fn: Callable[[DeviceProp], int] + value_fn: Callable[[DeviceProp], StateType | datetime.datetime] @dataclass @@ -43,6 +49,15 @@ class RoborockSensorDescription( """A class that describes Roborock sensors.""" +def _dock_error_value_fn(properties: DeviceProp) -> str | None: + if ( + status := properties.status.dock_error_status + ) is not None and properties.status.dock_type != RoborockDockTypeCode.no_dock: + return status.name + + return None + + SENSOR_DESCRIPTIONS = [ RoborockSensorDescription( native_unit_of_measurement=UnitOfTime.SECONDS, @@ -86,6 +101,7 @@ SENSOR_DESCRIPTIONS = [ 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 +110,7 @@ SENSOR_DESCRIPTIONS = [ 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 +126,7 @@ SENSOR_DESCRIPTIONS = [ 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 +134,7 @@ SENSOR_DESCRIPTIONS = [ 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 +153,49 @@ SENSOR_DESCRIPTIONS = [ 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=_dock_error_value_fn, + 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, + ), ] @@ -174,7 +236,7 @@ class RoborockSensorEntity(RoborockCoordinatedEntity, SensorEntity): self.entity_description = description @property - def native_value(self) -> StateType: + def native_value(self) -> StateType | datetime.datetime: """Return the value reported by the sensor.""" return self.entity_description.value_fn( self.coordinator.roborock_device_info.props diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index 20e90488ad3..53c536494f9 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" }, @@ -139,7 +176,8 @@ "moderate": "Moderate", "high": "High", "intense": "Intense", - "custom": "[%key:component::roborock::entity::select::mop_mode::state::custom%]" + "custom": "[%key:component::roborock::entity::select::mop_mode::state::custom%]", + "custom_water_flow": "Custom water flow" } } }, diff --git a/homeassistant/components/roku/media_player.py b/homeassistant/components/roku/media_player.py index 05f782b37c4..62a1a181459 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 @@ class RokuMediaPlayer(RokuEntity, MediaPlayerEntity): 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 f480839388c..cd37e089c9f 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 @@ class RoombaBinStatus(IRobotEntity, BinarySensorEntity): """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 ea08829cba6..db517a065ea 100644 --- a/homeassistant/components/roomba/braava.py +++ b/homeassistant/components/roomba/braava.py @@ -29,6 +29,8 @@ SUPPORT_BRAAVA = SUPPORT_IROBOT | VacuumEntityFeature.FAN_SPEED 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 @@ class BraavaJet(IRobotVacuum): 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 @@ class BraavaJet(IRobotVacuum): 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 8b909392250..a48b3638608 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 @@ class IRobotVacuum(IRobotEntity, StateVacuumEntity): """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 7cac9a3ba52..2c50508a637 100644 --- a/homeassistant/components/roomba/roomba.py +++ b/homeassistant/components/roomba/roomba.py @@ -42,10 +42,8 @@ class RoombaVacuum(IRobotVacuum): 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 @@ class RoombaVacuumCarpetBoost(RoombaVacuum): 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 e71555598cb..63521a622cd 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 @@ _LOGGER = logging.getLogger(__package__) 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 155eb68f593..c11e9cbe89f 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 @@ from .const import ( 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 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 29df676cb76..7c11aac7f68 100644 --- a/homeassistant/components/ruckus_unleashed/coordinator.py +++ b/homeassistant/components/ruckus_unleashed/coordinator.py @@ -3,9 +3,10 @@ from datetime import timedelta 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 @@ class RuckusUnleashedDataUpdateCoordinator(DataUpdateCoordinator): 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 0e0d2f103c4..df5027ebaa8 100644 --- a/homeassistant/components/ruckus_unleashed/device_tracker.py +++ b/homeassistant/components/ruckus_unleashed/device_tracker.py @@ -103,20 +103,16 @@ class RuckusUnleashedDevice(CoordinatorEntity, ScannerEntity): @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 8ff69fb1aa9..edaf0aa95d2 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 d6e3212b4ea..769cde67d7a 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 12a5ae99570..866279af973 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 @@ class SAJsensor(SensorEntity): 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 @@ class SAJsensor(SensorEntity): 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 e0ecbaac024..2b6373efc24 100644 --- a/homeassistant/components/samsungtv/entity.py +++ b/homeassistant/components/samsungtv/entity.py @@ -21,7 +21,8 @@ class SamsungTVEntity(Entity): 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 9461eb86af6..a3f35b65555 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.36.1" ], "ssdp": [ { diff --git a/homeassistant/components/samsungtv/media_player.py b/homeassistant/components/samsungtv/media_player.py index 06783314b4c..87174b13dd6 100644 --- a/homeassistant/components/samsungtv/media_player.py +++ b/homeassistant/components/samsungtv/media_player.py @@ -72,6 +72,7 @@ class SamsungTVDevice(SamsungTVEntity, MediaPlayerEntity): """Representation of a Samsung TV.""" _attr_source_list: list[str] + _attr_device_class = MediaPlayerDeviceClass.TV def __init__( self, @@ -90,7 +91,6 @@ class SamsungTVDevice(SamsungTVEntity, MediaPlayerEntity): self._playing: bool = True self._attr_is_volume_muted: bool = False - self._attr_device_class = MediaPlayerDeviceClass.TV self._attr_source_list = list(SOURCES) self._app_list: dict[str, str] | None = None self._app_list_event: asyncio.Event = asyncio.Event() diff --git a/homeassistant/components/schedule/__init__.py b/homeassistant/components/schedule/__init__.py index 2e5fcc27715..2f7831fedd4 100644 --- a/homeassistant/components/schedule/__init__.py +++ b/homeassistant/components/schedule/__init__.py @@ -30,9 +30,6 @@ from homeassistant.helpers.collection import ( 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 @@ class ScheduleStorageCollection(DictStorageCollection): 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 b9911e0544b..00000000000 --- 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 cf95e190e88..feaa95864d5 100644 --- a/homeassistant/components/schlage/__init__.py +++ b/homeassistant/components/schlage/__init__.py @@ -11,7 +11,12 @@ from homeassistant.core import HomeAssistant 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 00000000000..749a961a53b --- /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/strings.json b/homeassistant/components/schlage/strings.json index f3612bb96b8..076ed97e298 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 77131ccb225..bb8c233983d 100644 --- a/homeassistant/components/scrape/sensor.py +++ b/homeassistant/components/scrape/sensor.py @@ -192,9 +192,7 @@ class ScrapeSensor(CoordinatorEntity[ScrapeCoordinator], ManualTriggerSensorEnti 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 3370c196c3c..7276ec28323 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 @@ PLATFORMS = [ 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,90 @@ 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] +async def _async_migrate_entries( + hass: HomeAssistant, config_entry: ConfigEntry +) -> None: + """Migrate to new entity names.""" + entity_registry = er.async_get(hass) - _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], - } + 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 -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 - ), + _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() + if source_key not in ENTITY_MIGRATIONS: + continue - 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 + _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, ) - await self.gateway.async_connect(**connect_info) + continue + if device == "pump" and source_index is None: + _LOGGER.debug( + "Unable to parse 'source_index' from existing unique_id for pump entity '%s'", + source_key, + ) + continue + 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) - 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 + 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: + 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 30577584494..9192458dde4 100644 --- a/homeassistant/components/screenlogic/binary_sensor.py +++ b/homeassistant/components/screenlogic/binary_sensor.py @@ -1,27 +1,171 @@ """Support for a ScreenLogic Binary Sensor.""" -from screenlogicpy.const import CODE, DATA as SL_DATA, DEVICE_TYPE, EQUIPMENT, ON_OFF +from copy import copy +from dataclasses import dataclass +import logging + +from screenlogicpy.const.common import ON_OFF +from screenlogicpy.const.data import ATTR, DEVICE, GROUP, VALUE +from screenlogicpy.const.msg import CODE +from screenlogicpy.device_const.system import EQUIPMENT_FLAG 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 - -SL_DEVICE_TYPE_TO_HA_DEVICE_CLASS = {DEVICE_TYPE.ALARM: BinarySensorDeviceClass.PROBLEM} - -SUPPORTED_CONFIG_BINARY_SENSORS = ( - "freeze_mode", - "pool_delay", - "spa_delay", - "cleaner_delay", +from .const import DOMAIN as SL_DOMAIN +from .coordinator import ScreenlogicDataUpdateCoordinator +from .entity import ( + ScreenlogicEntity, + ScreenLogicEntityDescription, + ScreenLogicPushEntity, + ScreenLogicPushEntityDescription, ) +from .util import cleanup_excluded_entity + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class ScreenLogicBinarySensorDescription( + BinarySensorEntityDescription, ScreenLogicEntityDescription +): + """A class that describes ScreenLogic binary sensor eneites.""" + + +@dataclass +class ScreenLogicPushBinarySensorDescription( + ScreenLogicBinarySensorDescription, ScreenLogicPushEntityDescription +): + """Describes a ScreenLogicPushBinarySensor.""" + + +SUPPORTED_CORE_SENSORS = [ + ScreenLogicPushBinarySensorDescription( + subscription_code=CODE.STATUS_CHANGED, + data_root=(DEVICE.CONTROLLER, GROUP.SENSOR), + key=VALUE.ACTIVE_ALERT, + device_class=BinarySensorDeviceClass.PROBLEM, + ), + ScreenLogicPushBinarySensorDescription( + subscription_code=CODE.STATUS_CHANGED, + data_root=(DEVICE.CONTROLLER, GROUP.SENSOR), + key=VALUE.CLEANER_DELAY, + ), + ScreenLogicPushBinarySensorDescription( + subscription_code=CODE.STATUS_CHANGED, + data_root=(DEVICE.CONTROLLER, GROUP.SENSOR), + key=VALUE.FREEZE_MODE, + ), + ScreenLogicPushBinarySensorDescription( + subscription_code=CODE.STATUS_CHANGED, + data_root=(DEVICE.CONTROLLER, GROUP.SENSOR), + key=VALUE.POOL_DELAY, + ), + ScreenLogicPushBinarySensorDescription( + subscription_code=CODE.STATUS_CHANGED, + data_root=(DEVICE.CONTROLLER, GROUP.SENSOR), + key=VALUE.SPA_DELAY, + ), +] + +SUPPORTED_PUMP_SENSORS = [ + ScreenLogicBinarySensorDescription( + data_root=(DEVICE.PUMP,), + key=VALUE.STATE, + ) +] + +SUPPORTED_INTELLICHEM_SENSORS = [ + ScreenLogicPushBinarySensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.ALARM), + key=VALUE.FLOW_ALARM, + device_class=BinarySensorDeviceClass.PROBLEM, + ), + ScreenLogicPushBinarySensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.ALARM), + key=VALUE.ORP_HIGH_ALARM, + device_class=BinarySensorDeviceClass.PROBLEM, + ), + ScreenLogicPushBinarySensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.ALARM), + key=VALUE.ORP_LOW_ALARM, + device_class=BinarySensorDeviceClass.PROBLEM, + ), + ScreenLogicPushBinarySensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.ALARM), + key=VALUE.ORP_SUPPLY_ALARM, + device_class=BinarySensorDeviceClass.PROBLEM, + ), + ScreenLogicPushBinarySensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.ALARM), + key=VALUE.PH_HIGH_ALARM, + device_class=BinarySensorDeviceClass.PROBLEM, + ), + ScreenLogicPushBinarySensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.ALARM), + key=VALUE.PH_LOW_ALARM, + device_class=BinarySensorDeviceClass.PROBLEM, + ), + ScreenLogicPushBinarySensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.ALARM), + key=VALUE.PH_SUPPLY_ALARM, + device_class=BinarySensorDeviceClass.PROBLEM, + ), + ScreenLogicPushBinarySensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.ALARM), + key=VALUE.PROBE_FAULT_ALARM, + device_class=BinarySensorDeviceClass.PROBLEM, + ), + ScreenLogicPushBinarySensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.ALERT), + key=VALUE.ORP_LIMIT, + ), + ScreenLogicPushBinarySensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.ALERT), + key=VALUE.PH_LIMIT, + ), + ScreenLogicPushBinarySensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.ALERT), + key=VALUE.PH_LOCKOUT, + ), + ScreenLogicPushBinarySensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.WATER_BALANCE), + key=VALUE.CORROSIVE, + device_class=BinarySensorDeviceClass.PROBLEM, + ), + ScreenLogicPushBinarySensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.WATER_BALANCE), + key=VALUE.SCALING, + device_class=BinarySensorDeviceClass.PROBLEM, + ), +] + +SUPPORTED_SCG_SENSORS = [ + ScreenLogicBinarySensorDescription( + data_root=(DEVICE.SCG, GROUP.SENSOR), + key=VALUE.STATE, + ) +] async def async_setup_entry( @@ -30,132 +174,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] + gateway = coordinator.gateway - # Generic binary sensor - entities.append( - ScreenLogicStatusBinarySensor(coordinator, "chem_alarm", CODE.STATUS_CHANGED) - ) + for core_sensor_description in SUPPORTED_CORE_SENSORS: + if ( + gateway.get_data( + *core_sensor_description.data_root, core_sensor_description.key + ) + is not None + ): + entities.append( + ScreenLogicPushBinarySensor(coordinator, core_sensor_description) + ) - 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 + for p_index, p_data in gateway.get_data(DEVICE.PUMP).items(): + if not p_data or not p_data.get(VALUE.DATA): + continue + for proto_pump_sensor_description in SUPPORTED_PUMP_SENSORS: + entities.append( + ScreenLogicPumpBinarySensor( + coordinator, copy(proto_pump_sensor_description), p_index ) - 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 - ) - for chem_notif in chemistry[SL_DATA.KEY_NOTIFICATIONS] - if not chem_notif.startswith("_") - ] + chem_sensor_description: ScreenLogicPushBinarySensorDescription + for chem_sensor_description in SUPPORTED_INTELLICHEM_SENSORS: + chem_sensor_data_path = ( + *chem_sensor_description.data_root, + chem_sensor_description.key, ) + if EQUIPMENT_FLAG.INTELLICHEM not in gateway.equipment_flags: + cleanup_excluded_entity(coordinator, DOMAIN, chem_sensor_data_path) + continue + if gateway.get_data(*chem_sensor_data_path): + entities.append( + ScreenLogicPushBinarySensor(coordinator, chem_sensor_description) + ) - if config["equipment_flags"] & EQUIPMENT.FLAG_CHLORINATOR: - # SCG binary sensor - entities.append(ScreenlogicSCGBinarySensor(coordinator, "scg_status")) + scg_sensor_description: ScreenLogicBinarySensorDescription + for scg_sensor_description in SUPPORTED_SCG_SENSORS: + scg_sensor_data_path = ( + *scg_sensor_description.data_root, + scg_sensor_description.key, + ) + if EQUIPMENT_FLAG.CHLORINATOR not in gateway.equipment_flags: + cleanup_excluded_entity(coordinator, DOMAIN, scg_sensor_data_path) + continue + if gateway.get_data(*scg_sensor_data_path): + entities.append( + ScreenLogicBinarySensor(coordinator, scg_sensor_description) + ) async_add_entities(entities) -class ScreenLogicBinarySensorEntity(ScreenlogicEntity, BinarySensorEntity): - """Base class for all ScreenLogic binary sensor entities.""" +class ScreenLogicBinarySensor(ScreenlogicEntity, BinarySensorEntity): + """Representation of a ScreenLogic binary sensor entity.""" + entity_description: ScreenLogicBinarySensorDescription _attr_has_entity_name = True _attr_entity_category = EntityCategory.DIAGNOSTIC - @property - def name(self) -> str | None: - """Return the sensor name.""" - return self.sensor["name"] - - @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) - @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] + return self.entity_data[ATTR.VALUE] == ON_OFF.ON -class ScreenLogicStatusBinarySensor( - ScreenLogicBinarySensorEntity, ScreenLogicPushEntity -): - """Representation of a basic ScreenLogic sensor entity.""" +class ScreenLogicPushBinarySensor(ScreenLogicPushEntity, ScreenLogicBinarySensor): + """Representation of a ScreenLogic push binary sensor entity.""" + + entity_description: ScreenLogicPushBinarySensorDescription -class ScreenlogicChemistryAlarmBinarySensor( - ScreenLogicBinarySensorEntity, ScreenLogicPushEntity -): - """Representation of a ScreenLogic IntelliChem alarm binary sensor entity.""" +class ScreenLogicPumpBinarySensor(ScreenLogicBinarySensor): + """Representation of a ScreenLogic binary sensor entity for pump data.""" - @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 - ] - - -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.""" - - @property - def sensor(self) -> dict: - """Shortcut to access the sensor data.""" - return self.gateway_data[SL_DATA.KEY_CONFIG][self._data_key] + def __init__( + self, + coordinator: ScreenlogicDataUpdateCoordinator, + entity_description: ScreenLogicBinarySensorDescription, + pump_index: int, + ) -> None: + """Initialize of the entity.""" + entity_description.data_root = (DEVICE.PUMP, pump_index) + super().__init__(coordinator, entity_description) diff --git a/homeassistant/components/screenlogic/climate.py b/homeassistant/components/screenlogic/climate.py index cea546262ae..1d3f366a498 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.exceptions import HomeAssistantError 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,87 @@ 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 in gateway.get_data(DEVICE.BODY): + entities.append( + ScreenLogicClimate( + coordinator, + ScreenLogicClimateDescription( + subscription_code=CODE.STATUS_CHANGED, + data_root=(DEVICE.BODY,), + key=body_index, + ), + ) + ) 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._attr_min_temp = self.entity_data[ATTR.MIN_SETPOINT] + self._attr_max_temp = self.entity_data[ATTR.MAX_SETPOINT] + self._attr_name = self.entity_data[VALUE.HEAT_STATE][ATTR.NAME] 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"] - @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 +137,13 @@ class ScreenLogicClimate(ScreenLogicPushEntity, ClimateEntity, RestoreEntity): 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 +155,7 @@ class ScreenLogicClimate(ScreenLogicPushEntity, ClimateEntity, RestoreEntity): ): 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 +164,33 @@ class ScreenLogicClimate(ScreenLogicPushEntity, ClimateEntity, RestoreEntity): 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 +204,16 @@ class ScreenLogicClimate(ScreenLogicPushEntity, ClimateEntity, RestoreEntity): 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 77040bdb216..25d00e3a2ce 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 @@ class ScreenlogicConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """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 @@ class ScreenlogicConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 @@ class ScreenlogicConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 @@ class ScreenlogicConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 @@ class ScreenLogicOptionsFlowHandler(config_entries.OptionsFlow): """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 e4a5ea82186..8181e0f612a 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 00000000000..74f49927171 --- /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 00000000000..719cebc1ef6 --- /dev/null +++ b/homeassistant/components/screenlogic/data.py @@ -0,0 +1,120 @@ +"""Support for configurable supported data values for the ScreenLogic integration.""" +from screenlogicpy.const.data import DEVICE, VALUE + +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 ca949c4514c..92e700239ff 100644 --- a/homeassistant/components/screenlogic/diagnostics.py +++ b/homeassistant/components/screenlogic/diagnostics.py @@ -5,8 +5,8 @@ from typing import Any 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 955b73262a1..3b45aa699d3 100644 --- a/homeassistant/components/screenlogic/entity.py +++ b/homeassistant/components/screenlogic/entity.py @@ -1,52 +1,70 @@ """Base ScreenLogicEntity definitions.""" +from collections.abc import Callable +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 +from .util import generate_unique_id _LOGGER = logging.getLogger(__name__) +@dataclass +class ScreenLogicEntityRequiredKeyMixin: + """Mixin for required ScreenLogic entity data_path.""" + + data_root: ScreenLogicDataPath + + +@dataclass +class ScreenLogicEntityDescription( + EntityDescription, ScreenLogicEntityRequiredKeyMixin +): + """Base class for a ScreenLogic entity description.""" + + enabled_lambda: Callable[..., bool] | None = None + + 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_key = self.entity_description.key + self._data_path = (*self.entity_description.data_root, self._data_key) mac = self.mac + self._attr_unique_id = f"{mac}_{generate_unique_id(*self._data_path)}" + self._attr_name = self.entity_data[ATTR.NAME] 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 +74,11 @@ class ScreenlogicEntity(CoordinatorEntity[ScreenlogicDataUpdateCoordinator]): 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 +90,43 @@ class ScreenlogicEntity(CoordinatorEntity[ScreenlogicDataUpdateCoordinator]): """Refresh from a timed called.""" await self.coordinator.async_request_refresh() + @property + def entity_data(self) -> dict: + """Shortcut to the data for this entity.""" + try: + return self.gateway.get_data(*self._data_path, strict=True) + except KeyError as ke: + raise HomeAssistantError(f"Data not found: {self._data_path}") from ke + + +@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._subscription_code = entity_description.subscription_code self._last_update_success = True @callback @@ -114,7 +140,8 @@ class ScreenLogicPushEntity(ScreenlogicEntity): 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._subscription_code, ) ) @@ -129,17 +156,10 @@ class ScreenLogicPushEntity(ScreenlogicEntity): 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 +169,9 @@ class ScreenLogicCircuitEntity(ScreenLogicPushEntity): """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 3eae12178de..80499f7790a 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,48 @@ 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 ( + not circuit_data + or ((circuit_function := circuit_data.get(ATTR.FUNCTION)) is None) + or circuit_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_root=(DEVICE.CIRCUIT,), + key=circuit_index, + 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 5b8b8369427..4d9bbacf3a8 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.1"] } diff --git a/homeassistant/components/screenlogic/number.py b/homeassistant/components/screenlogic/number.py index e0d5d0e6a67..d3ed25f5570 100644 --- a/homeassistant/components/screenlogic/number.py +++ b/homeassistant/components/screenlogic/number.py @@ -1,26 +1,70 @@ """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 screenlogicpy.device_const.system import EQUIPMENT_FLAG -from homeassistant.components.number import NumberEntity +from homeassistant.components.number import ( + DOMAIN, + 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 +from .coordinator import ScreenlogicDataUpdateCoordinator +from .entity import ScreenlogicEntity, ScreenLogicEntityDescription +from .util import cleanup_excluded_entity, get_ha_unit _LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 1 -SUPPORTED_SCG_NUMBERS = ( - "scg_level1", - "scg_level2", -) + +@dataclass +class ScreenLogicNumberRequiredMixin: + """Describes a required mixin for a ScreenLogic number entity.""" + + set_value_name: str + set_value_args: tuple[tuple[str | int, ...], ...] + + +@dataclass +class ScreenLogicNumberDescription( + NumberEntityDescription, + ScreenLogicEntityDescription, + ScreenLogicNumberRequiredMixin, +): + """Describes a ScreenLogic number entity.""" + + +SUPPORTED_SCG_NUMBERS = [ + ScreenLogicNumberDescription( + set_value_name="async_set_scg_config", + set_value_args=( + (DEVICE.SCG, GROUP.CONFIGURATION, VALUE.POOL_SETPOINT), + (DEVICE.SCG, GROUP.CONFIGURATION, VALUE.SPA_SETPOINT), + ), + data_root=(DEVICE.SCG, GROUP.CONFIGURATION), + key=VALUE.POOL_SETPOINT, + entity_category=EntityCategory.CONFIG, + ), + ScreenLogicNumberDescription( + set_value_name="async_set_scg_config", + set_value_args=( + (DEVICE.SCG, GROUP.CONFIGURATION, VALUE.POOL_SETPOINT), + (DEVICE.SCG, GROUP.CONFIGURATION, VALUE.SPA_SETPOINT), + ), + data_root=(DEVICE.SCG, GROUP.CONFIGURATION), + key=VALUE.SPA_SETPOINT, + entity_category=EntityCategory.CONFIG, + ), +] async def async_setup_entry( @@ -29,66 +73,82 @@ 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 + + for scg_number_description in SUPPORTED_SCG_NUMBERS: + scg_number_data_path = ( + *scg_number_description.data_root, + scg_number_description.key, ) + if EQUIPMENT_FLAG.CHLORINATOR not in gateway.equipment_flags: + cleanup_excluded_entity(coordinator, DOMAIN, scg_number_data_path) + continue + if gateway.get_data(*scg_number_data_path): + entities.append(ScreenLogicNumber(coordinator, scg_number_description)) + + async_add_entities(entities) 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.""" + super().__init__(coordinator, entity_description) + if not callable( + func := getattr(self.gateway, entity_description.set_value_name) + ): + raise TypeError( + f"set_value_name '{entity_description.set_value_name}' is not a callable" + ) + self._set_value_func: Callable[..., bool] = func + self._set_value_args = entity_description.set_value_args + self._attr_native_unit_of_measurement = get_ha_unit( + self.entity_data.get(ATTR.UNIT) + ) + if entity_description.native_max_value is None and isinstance( + max_val := self.entity_data.get(ATTR.MAX_SETPOINT), int | float + ): + self._attr_native_max_value = max_val + if entity_description.native_min_value is None and isinstance( + min_val := self.entity_data.get(ATTR.MIN_SETPOINT), int | float + ): + self._attr_native_min_value = min_val + if entity_description.native_step is None and isinstance( + step := self.entity_data.get(ATTR.STEP), int | float + ): + self._attr_native_step = step @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_args: + data_key = data_path[-1] + args[data_key] = 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 3a9bc3cbee9..bbcf8458014 100644 --- a/homeassistant/components/screenlogic/sensor.py +++ b/homeassistant/components/screenlogic/sensor.py @@ -1,101 +1,228 @@ """Support for a ScreenLogic Sensor.""" -from typing import Any +from collections.abc import Callable +from copy import copy +from dataclasses import dataclass +import logging -from screenlogicpy.const import ( - CHEM_DOSING_STATE, - CODE, - DATA as SL_DATA, - DEVICE_TYPE, - EQUIPMENT, - STATE_TYPE, - UNIT, -) +from screenlogicpy.const.data import ATTR, DEVICE, GROUP, VALUE +from screenlogicpy.const.msg import CODE +from screenlogicpy.device_const.chemistry import DOSE_STATE +from screenlogicpy.device_const.pump import PUMP_TYPE +from screenlogicpy.device_const.system import EQUIPMENT_FLAG from 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.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 - -SUPPORTED_BASIC_SENSORS = ( - "air_temperature", - "saturation", +from .const import DOMAIN as SL_DOMAIN +from .coordinator import ScreenlogicDataUpdateCoordinator +from .entity import ( + ScreenlogicEntity, + ScreenLogicEntityDescription, + ScreenLogicPushEntity, + ScreenLogicPushEntityDescription, ) +from .util import cleanup_excluded_entity, get_ha_unit -SUPPORTED_BASIC_CHEM_SENSORS = ( - "orp", - "ph", -) +_LOGGER = logging.getLogger(__name__) -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", -) -SUPPORTED_SCG_SENSORS = ( - "scg_salt_ppm", - "scg_super_chlor_timer", -) +@dataclass +class ScreenLogicSensorMixin: + """Mixin for SecreenLogic sensor entity.""" -SUPPORTED_PUMP_SENSORS = ("currentWatts", "currentRPM", "currentGPM") + value_mod: Callable[[int | str], int | str] | None = None -SL_DEVICE_TYPE_TO_HA_DEVICE_CLASS = { - DEVICE_TYPE.DURATION: SensorDeviceClass.DURATION, - DEVICE_TYPE.ENUM: SensorDeviceClass.ENUM, - DEVICE_TYPE.ENERGY: SensorDeviceClass.POWER, - DEVICE_TYPE.POWER: SensorDeviceClass.POWER, - DEVICE_TYPE.TEMPERATURE: SensorDeviceClass.TEMPERATURE, - DEVICE_TYPE.VOLUME: SensorDeviceClass.VOLUME, -} -SL_STATE_TYPE_TO_HA_STATE_CLASS = { - STATE_TYPE.MEASUREMENT: SensorStateClass.MEASUREMENT, - STATE_TYPE.TOTAL_INCREASING: SensorStateClass.TOTAL_INCREASING, -} +@dataclass +class ScreenLogicSensorDescription( + ScreenLogicSensorMixin, SensorEntityDescription, ScreenLogicEntityDescription +): + """Describes a ScreenLogic sensor.""" -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, -} + +@dataclass +class ScreenLogicPushSensorDescription( + ScreenLogicSensorDescription, ScreenLogicPushEntityDescription +): + """Describes a ScreenLogic push sensor.""" + + +SUPPORTED_CORE_SENSORS = [ + ScreenLogicPushSensorDescription( + subscription_code=CODE.STATUS_CHANGED, + data_root=(DEVICE.CONTROLLER, GROUP.SENSOR), + key=VALUE.AIR_TEMPERATURE, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), +] + +SUPPORTED_PUMP_SENSORS = [ + ScreenLogicSensorDescription( + data_root=(DEVICE.PUMP,), + key=VALUE.WATTS_NOW, + device_class=SensorDeviceClass.POWER, + ), + ScreenLogicSensorDescription( + data_root=(DEVICE.PUMP,), + key=VALUE.GPM_NOW, + enabled_lambda=lambda type: type != PUMP_TYPE.INTELLIFLO_VS, + ), + ScreenLogicSensorDescription( + data_root=(DEVICE.PUMP,), + key=VALUE.RPM_NOW, + enabled_lambda=lambda type: type != PUMP_TYPE.INTELLIFLO_VF, + ), +] + +SUPPORTED_INTELLICHEM_SENSORS = [ + ScreenLogicPushSensorDescription( + subscription_code=CODE.STATUS_CHANGED, + data_root=(DEVICE.CONTROLLER, GROUP.SENSOR), + key=VALUE.ORP, + state_class=SensorStateClass.MEASUREMENT, + ), + ScreenLogicPushSensorDescription( + subscription_code=CODE.STATUS_CHANGED, + data_root=(DEVICE.CONTROLLER, GROUP.SENSOR), + key=VALUE.PH, + state_class=SensorStateClass.MEASUREMENT, + ), + ScreenLogicPushSensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.SENSOR), + key=VALUE.ORP_NOW, + state_class=SensorStateClass.MEASUREMENT, + ), + ScreenLogicPushSensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.SENSOR), + key=VALUE.PH_NOW, + state_class=SensorStateClass.MEASUREMENT, + ), + ScreenLogicPushSensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.SENSOR), + key=VALUE.ORP_SUPPLY_LEVEL, + state_class=SensorStateClass.MEASUREMENT, + value_mod=lambda val: int(val) - 1, + ), + ScreenLogicPushSensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.SENSOR), + key=VALUE.PH_SUPPLY_LEVEL, + state_class=SensorStateClass.MEASUREMENT, + value_mod=lambda val: int(val) - 1, + ), + ScreenLogicPushSensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.SENSOR), + key=VALUE.PH_PROBE_WATER_TEMP, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + ScreenLogicPushSensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.SENSOR), + key=VALUE.SATURATION, + state_class=SensorStateClass.MEASUREMENT, + ), + ScreenLogicPushSensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.CONFIGURATION), + key=VALUE.CALCIUM_HARNESS, + ), + ScreenLogicPushSensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.CONFIGURATION), + key=VALUE.CYA, + ), + ScreenLogicPushSensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.CONFIGURATION), + key=VALUE.ORP_SETPOINT, + ), + ScreenLogicPushSensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.CONFIGURATION), + key=VALUE.PH_SETPOINT, + ), + ScreenLogicPushSensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.CONFIGURATION), + key=VALUE.TOTAL_ALKALINITY, + ), + ScreenLogicPushSensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.CONFIGURATION), + key=VALUE.SALT_TDS_PPM, + ), + ScreenLogicPushSensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.DOSE_STATUS), + key=VALUE.ORP_DOSING_STATE, + device_class=SensorDeviceClass.ENUM, + options=["Dosing", "Mixing", "Monitoring"], + value_mod=lambda val: DOSE_STATE(val).title, + ), + ScreenLogicPushSensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.DOSE_STATUS), + key=VALUE.ORP_LAST_DOSE_TIME, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + ScreenLogicPushSensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.DOSE_STATUS), + key=VALUE.ORP_LAST_DOSE_VOLUME, + device_class=SensorDeviceClass.VOLUME, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + ScreenLogicPushSensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.DOSE_STATUS), + key=VALUE.PH_DOSING_STATE, + device_class=SensorDeviceClass.ENUM, + options=["Dosing", "Mixing", "Monitoring"], + value_mod=lambda val: DOSE_STATE(val).title, + ), + ScreenLogicPushSensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.DOSE_STATUS), + key=VALUE.PH_LAST_DOSE_TIME, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + ScreenLogicPushSensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.DOSE_STATUS), + key=VALUE.PH_LAST_DOSE_VOLUME, + device_class=SensorDeviceClass.VOLUME, + state_class=SensorStateClass.TOTAL_INCREASING, + ), +] + +SUPPORTED_SCG_SENSORS = [ + ScreenLogicSensorDescription( + data_root=(DEVICE.SCG, GROUP.SENSOR), + key=VALUE.SALT_PPM, + state_class=SensorStateClass.MEASUREMENT, + ), + ScreenLogicSensorDescription( + data_root=(DEVICE.SCG, GROUP.CONFIGURATION), + key=VALUE.SUPER_CHLOR_TIMER, + ), +] async def async_setup_entry( @@ -104,171 +231,114 @@ 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"] + gateway = coordinator.gateway - # 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) - ) - - # 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. + for core_sensor_description in SUPPORTED_CORE_SENSORS: if ( - equipment_flags & EQUIPMENT.FLAG_INTELLICHEM - and sensor_name in SUPPORTED_BASIC_CHEM_SENSORS + gateway.get_data( + *core_sensor_description.data_root, core_sensor_description.key + ) + is not None ): + entities.append(ScreenLogicPushSensor(coordinator, core_sensor_description)) + + for pump_index, pump_data in gateway.get_data(DEVICE.PUMP).items(): + if not pump_data or not pump_data.get(VALUE.DATA): + continue + pump_type = pump_data[VALUE.TYPE] + for proto_pump_sensor_description in SUPPORTED_PUMP_SENSORS: + if not pump_data.get(proto_pump_sensor_description.key): + continue entities.append( - ScreenLogicStatusSensor(coordinator, sensor_name, CODE.STATUS_CHANGED) + ScreenLogicPumpSensor( + coordinator, + copy(proto_pump_sensor_description), + pump_index, + pump_type, + ) ) - # 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 - ) - ) - - # 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 - ] + chem_sensor_description: ScreenLogicPushSensorDescription + for chem_sensor_description in SUPPORTED_INTELLICHEM_SENSORS: + chem_sensor_data_path = ( + *chem_sensor_description.data_root, + chem_sensor_description.key, ) + if EQUIPMENT_FLAG.INTELLICHEM not in gateway.equipment_flags: + cleanup_excluded_entity(coordinator, DOMAIN, chem_sensor_data_path) + continue + if gateway.get_data(*chem_sensor_data_path): + chem_sensor_description.entity_category = EntityCategory.DIAGNOSTIC + entities.append(ScreenLogicPushSensor(coordinator, chem_sensor_description)) + + scg_sensor_description: ScreenLogicSensorDescription + for scg_sensor_description in SUPPORTED_SCG_SENSORS: + scg_sensor_data_path = ( + *scg_sensor_description.data_root, + scg_sensor_description.key, + ) + if EQUIPMENT_FLAG.CHLORINATOR not in gateway.equipment_flags: + cleanup_excluded_entity(coordinator, DOMAIN, scg_sensor_data_path) + continue + if gateway.get_data(*scg_sensor_data_path): + scg_sensor_description.entity_category = EntityCategory.DIAGNOSTIC + entities.append(ScreenLogicSensor(coordinator, scg_sensor_description)) async_add_entities(entities) -class ScreenLogicSensorEntity(ScreenlogicEntity, SensorEntity): - """Base class for all ScreenLogic sensor entities.""" +class ScreenLogicSensor(ScreenlogicEntity, SensorEntity): + """Representation of a ScreenLogic sensor entity.""" + entity_description: ScreenLogicSensorDescription _attr_has_entity_name = True - @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) - - @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 + def __init__( + self, + coordinator: ScreenlogicDataUpdateCoordinator, + entity_description: ScreenLogicSensorDescription, + ) -> None: + """Initialize of the entity.""" + super().__init__(coordinator, entity_description) + self._attr_native_unit_of_measurement = get_ha_unit( + self.entity_data.get(ATTR.UNIT) ) - @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") - @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.""" +class ScreenLogicPushSensor(ScreenLogicSensor, ScreenLogicPushEntity): + """Representation of a ScreenLogic push sensor entity.""" + + entity_description: ScreenLogicPushSensorDescription -class ScreenLogicPumpSensor(ScreenLogicSensorEntity): - """Representation of a ScreenLogic pump sensor entity.""" +class ScreenLogicPumpSensor(ScreenLogicSensor): + """Representation of a ScreenLogic pump sensor.""" - 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 + _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_state_class = SensorStateClass.MEASUREMENT - @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] + def __init__( + self, + coordinator: ScreenlogicDataUpdateCoordinator, + entity_description: ScreenLogicSensorDescription, + pump_index: int, + pump_type: int, + ) -> None: + """Initialize of the entity.""" + entity_description.data_root = (DEVICE.PUMP, pump_index) + super().__init__(coordinator, entity_description) + if entity_description.enabled_lambda: + self._attr_entity_registry_enabled_default = ( + entity_description.enabled_lambda(pump_type) + ) diff --git a/homeassistant/components/screenlogic/switch.py b/homeassistant/components/screenlogic/switch.py index 96bced70867..4900ed938a1 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,46 @@ 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 ( + not circuit_data + or ((circuit_function := circuit_data.get(ATTR.FUNCTION)) is None) + or circuit_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_root=(DEVICE.CIRCUIT,), + key=circuit_index, + 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 00000000000..928effc73fc --- /dev/null +++ b/homeassistant/components/screenlogic/util.py @@ -0,0 +1,48 @@ +"""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, SL_UNIT_TO_HA_UNIT, ScreenLogicDataPath +from .coordinator import ScreenlogicDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +def generate_unique_id(*args: str | int | None) -> str: + """Generate new unique_id for a screenlogic entity from specified parameters.""" + _LOGGER.debug("gen_uid called with %s", args) + if len(args) == 3: + if args[2] in SHARED_VALUES: + if args[1] is not None and (isinstance(args[1], int) or args[1].isdigit()): + return f"{args[0]}_{args[1]}_{args[2]}" + return f"{args[0]}_{args[2]}" + return f"{args[2]}" + return f"{args[1]}" + + +def get_ha_unit(sl_unit) -> str: + """Return equivalent Home Assistant unit of measurement if exists.""" + if (ha_unit := SL_UNIT_TO_HA_UNIT.get(sl_unit)) is not None: + return ha_unit + return sl_unit + + +def cleanup_excluded_entity( + coordinator: ScreenlogicDataUpdateCoordinator, + platform_domain: str, + data_path: ScreenLogicDataPath, +) -> 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}_{generate_unique_id(*data_path)}" + 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 13b25a00053..716f0197c8b 100644 --- a/homeassistant/components/script/__init__.py +++ b/homeassistant/components/script/__init__.py @@ -42,9 +42,6 @@ import homeassistant.helpers.config_validation as cv 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 @@ async def _async_process_config( 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 diff --git a/homeassistant/components/script/blueprints/confirmable_notification.yaml b/homeassistant/components/script/blueprints/confirmable_notification.yaml index 37e04351d9a..c5f42494f02 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 b1afc318b51..00000000000 --- 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 69796800e61..ac9a13850d6 100644 --- a/homeassistant/components/search/__init__.py +++ b/homeassistant/components/search/__init__.py @@ -15,7 +15,10 @@ from homeassistant.helpers import ( 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 @@ class Searcher: 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 a8034588ed1..4997e088a54 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 6660c8383d0..00000000000 --- 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 2aee20be5ae..094ecbdfcf7 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 @@ class SenseDevice(BinarySensorEntity): 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 8a89d6d8531..7ef1caefe48 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.1"] + "requirements": ["sense-energy==0.12.2"] } diff --git a/homeassistant/components/sensibo/climate.py b/homeassistant/components/sensibo/climate.py index 2e2b92179f0..f8ecd1b9b80 100644 --- a/homeassistant/components/sensibo/climate.py +++ b/homeassistant/components/sensibo/climate.py @@ -188,6 +188,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 @@ -201,8 +203,6 @@ class SensiboClimate(SensiboDeviceBaseEntity, ClimateEntity): 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/manifest.json b/homeassistant/components/sensibo/manifest.json index 42964ddce8f..016b3a1e9d9 100644 --- a/homeassistant/components/sensibo/manifest.json +++ b/homeassistant/components/sensibo/manifest.json @@ -15,5 +15,5 @@ "iot_class": "cloud_polling", "loggers": ["pysensibo"], "quality_scale": "platinum", - "requirements": ["pysensibo==1.0.33"] + "requirements": ["pysensibo==1.0.35"] } diff --git a/homeassistant/components/sensibo/number.py b/homeassistant/components/sensibo/number.py index 94765a17a4d..d4e268ea44d 100644 --- a/homeassistant/components/sensibo/number.py +++ b/homeassistant/components/sensibo/number.py @@ -7,9 +7,13 @@ from typing import Any 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 @@ DEVICE_NUMBER_TYPES = ( 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 @@ DEVICE_NUMBER_TYPES = ( 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 7208902456e..f6d62d79dff 100644 --- a/homeassistant/components/sensibo/sensor.py +++ b/homeassistant/components/sensibo/sensor.py @@ -107,6 +107,7 @@ MOTION_SENSOR_TYPES: tuple[SensiboMotionSensorEntityDescription, ...] = ( 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 @@ DEVICE_SENSOR_TYPES: tuple[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 @@ DEVICE_SENSOR_TYPES: tuple[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 @@ DEVICE_SENSOR_TYPES: tuple[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 @@ ELEMENT_SENSOR_TYPES: tuple[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 @@ class SensiboMotionSensor(SensiboMotionBaseEntity, SensorEntity): 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 @@ class SensiboDeviceSensor(SensiboDeviceBaseEntity, SensorEntity): 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/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 446dd60fd92..2cab631d1f0 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -13,7 +13,7 @@ from typing import Any, Final, Self, cast, final 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/const.py b/homeassistant/components/sensor/const.py index 139725ee1ab..e8b1742f315 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -542,6 +542,7 @@ DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = { }, SensorDeviceClass.VOLTAGE: set(UnitOfElectricPotential), SensorDeviceClass.VOLUME: set(UnitOfVolume), + SensorDeviceClass.VOLUME_STORAGE: set(UnitOfVolume), SensorDeviceClass.WATER: { UnitOfVolume.CENTUM_CUBIC_FEET, UnitOfVolume.CUBIC_FEET, diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index e5a35187c99..2ef1b6854fc 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -30,19 +30,13 @@ from homeassistant.const import ( 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 149e503d0f8..fa1044414bb 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/seven_segments/manifest.json b/homeassistant/components/seven_segments/manifest.json index ed8638d8419..2b730648e22 100644 --- a/homeassistant/components/seven_segments/manifest.json +++ b/homeassistant/components/seven_segments/manifest.json @@ -4,5 +4,5 @@ "codeowners": ["@fabaff"], "documentation": "https://www.home-assistant.io/integrations/seven_segments", "iot_class": "local_polling", - "requirements": ["Pillow==10.0.0"] + "requirements": ["Pillow==10.0.1"] } diff --git a/homeassistant/components/sharkiq/vacuum.py b/homeassistant/components/sharkiq/vacuum.py index 8c6c4a9197a..9510b7d3f66 100644 --- a/homeassistant/components/sharkiq/vacuum.py +++ b/homeassistant/components/sharkiq/vacuum.py @@ -88,7 +88,13 @@ class SharkVacuumEntity(CoordinatorEntity[SharkIqUpdateCoordinator], StateVacuum 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 @@ class SharkVacuumEntity(CoordinatorEntity[SharkIqUpdateCoordinator], StateVacuum @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 @@ class SharkVacuumEntity(CoordinatorEntity[SharkIqUpdateCoordinator], StateVacuum 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 09d9e3655f0..5efc5c849d7 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -58,6 +58,7 @@ BLOCK_PLATFORMS: Final = [ Platform.BINARY_SENSOR, Platform.BUTTON, Platform.COVER, + Platform.EVENT, Platform.LIGHT, Platform.SENSOR, Platform.SWITCH, @@ -73,6 +74,7 @@ RPC_PLATFORMS: Final = [ 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 33b4caa5034..0275b805208 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -181,3 +181,8 @@ PUSH_UPDATE_ISSUE_ID = "push_update_{unique}" 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 d645b09799f..1a8081b2053 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -44,6 +44,10 @@ from .const import ( 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, @@ -166,6 +170,7 @@ class ShellyBlockCoordinator(ShellyCoordinatorBase[BlockDevice]): self._last_input_events_count: dict = {} self._last_target_temp: float | None = None self._push_update_failures: int = 0 + self._input_event_listeners: list[Callable[[dict[str, Any]], None]] = [] entry.async_on_unload( self.async_add_listener(self._async_device_updates_handler) @@ -174,6 +179,19 @@ class ShellyBlockCoordinator(ShellyCoordinatorBase[BlockDevice]): hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._handle_ha_stop) ) + @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_device_updates_handler(self) -> None: """Handle device updates.""" @@ -238,6 +256,10 @@ class ShellyBlockCoordinator(ShellyCoordinatorBase[BlockDevice]): continue if event_type in INPUTS_EVENTS_DICT: + for event_callback in self._input_event_listeners: + event_callback( + {"channel": channel, "event": INPUTS_EVENTS_DICT[event_type]} + ) self.hass.bus.async_fire( EVENT_SHELLY_CLICK, { @@ -384,6 +406,8 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): 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 +432,32 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): 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 +501,8 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): ) 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 +513,9 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): 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 1dc7573b738..5afa5f8b727 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -332,6 +332,7 @@ class ShellyBlockEntity(CoordinatorEntity[ShellyBlockCoordinator]): ) 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 @@ class ShellyRpcEntity(CoordinatorEntity[ShellyRpcCoordinator]): """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 @@ class ShellyRpcAttributeEntity(ShellyRpcEntity, Entity): 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 00000000000..1b0fedd5cda --- /dev/null +++ b/homeassistant/components/shelly/event.py @@ -0,0 +1,194 @@ +"""Event for Shelly.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Final + +from aioshelly.block_device import Block + +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 ( + BASIC_INPUTS_EVENTS_TYPES, + RPC_INPUTS_EVENTS_TYPES, + SHIX3_1_INPUTS_EVENTS_TYPES, +) +from .coordinator import ShellyBlockCoordinator, ShellyRpcCoordinator, get_entry_data +from .entity import ShellyBlockEntity +from .utils import ( + async_remove_shelly_entity, + get_device_entry_gen, + get_rpc_entity_name, + get_rpc_key_instances, + is_block_momentary_input, + is_rpc_momentary_input, +) + + +@dataclass +class ShellyBlockEventDescription(EventEntityDescription): + """Class to describe Shelly event.""" + + removal_condition: Callable[[dict, Block], bool] | None = None + + +@dataclass +class ShellyRpcEventDescription(EventEntityDescription): + """Class to describe Shelly event.""" + + removal_condition: Callable[[dict, dict, str], bool] | None = None + + +BLOCK_EVENT: Final = ShellyBlockEventDescription( + key="input", + translation_key="input", + device_class=EventDeviceClass.BUTTON, + removal_condition=lambda settings, block: not is_block_momentary_input( + settings, block, True + ), +) +RPC_EVENT: Final = ShellyRpcEventDescription( + 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.""" + entities: list[ShellyBlockEvent | ShellyRpcEvent] = [] + + coordinator: ShellyRpcCoordinator | ShellyBlockCoordinator | None = None + + if get_device_entry_gen(config_entry) == 2: + coordinator = get_entry_data(hass)[config_entry.entry_id].rpc + if TYPE_CHECKING: + assert coordinator + + 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)) + else: + coordinator = get_entry_data(hass)[config_entry.entry_id].block + if TYPE_CHECKING: + assert coordinator + assert coordinator.device.blocks + + for block in coordinator.device.blocks: + if ( + "inputEvent" not in block.sensor_ids + or "inputEventCnt" not in block.sensor_ids + ): + continue + + if BLOCK_EVENT.removal_condition and BLOCK_EVENT.removal_condition( + coordinator.device.settings, block + ): + channel = int(block.channel or 0) + 1 + unique_id = f"{coordinator.mac}-{block.description}-{channel}" + async_remove_shelly_entity(hass, EVENT_DOMAIN, unique_id) + else: + entities.append(ShellyBlockEvent(coordinator, block, BLOCK_EVENT)) + + async_add_entities(entities) + + +class ShellyBlockEvent(ShellyBlockEntity, EventEntity): + """Represent Block event entity.""" + + _attr_should_poll = False + entity_description: ShellyBlockEventDescription + + def __init__( + self, + coordinator: ShellyBlockCoordinator, + block: Block, + description: ShellyBlockEventDescription, + ) -> None: + """Initialize Shelly entity.""" + super().__init__(coordinator, block) + self.channel = channel = int(block.channel or 0) + 1 + self._attr_unique_id = f"{super().unique_id}-{channel}" + + if coordinator.model == "SHIX3-1": + self._attr_event_types = list(SHIX3_1_INPUTS_EVENTS_TYPES) + else: + self._attr_event_types = list(BASIC_INPUTS_EVENTS_TYPES) + 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["channel"] == self.channel: + self._trigger_event(event["event"]) + self.async_write_ha_state() + + +class ShellyRpcEvent(CoordinatorEntity[ShellyRpcCoordinator], EventEntity): + """Represent RPC event entity.""" + + _attr_should_poll = False + entity_description: ShellyRpcEventDescription + + def __init__( + self, + coordinator: ShellyRpcCoordinator, + key: str, + description: ShellyRpcEventDescription, + ) -> 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_entity_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 abcca888005..99ccd9ab2ff 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -363,6 +363,14 @@ RPC_SENSORS: Final = { 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 @@ RPC_SENSORS: Final = { 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 @@ RPC_SENSORS: Final = { 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 @@ RPC_SENSORS: Final = { 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 @@ RPC_SENSORS: Final = { 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 @@ RPC_SENSORS: Final = { 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 @@ RPC_SENSORS: Final = { 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 @@ RPC_SENSORS: Final = { 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 043ff419742..b12ad3e4823 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -98,6 +98,28 @@ } } }, + "event": { + "input": { + "state_attributes": { + "event_type": { + "state": { + "btn_down": "Button down", + "btn_up": "Button up", + "double_push": "Double push", + "double": "Double push", + "long_push": "Long push", + "long_single": "Long push and then short push", + "long": "Long push", + "single_long": "Short push and then long push", + "single_push": "Single push", + "single": "Single push", + "triple_push": "Triple push", + "triple": "Triple push" + } + } + } + } + }, "sensor": { "operation": { "state": { @@ -120,7 +142,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 3b2096f0c1a..d4528f55288 100644 --- a/homeassistant/components/shelly/update.py +++ b/homeassistant/components/shelly/update.py @@ -18,12 +18,12 @@ from homeassistant.components.update import ( ) 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 @@ class RpcUpdateEntity(ShellyRpcAttributeEntity, UpdateEntity): ) -> 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 @@ class RpcUpdateEntity(ShellyRpcAttributeEntity, UpdateEntity): 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 @@ class RpcUpdateEntity(ShellyRpcAttributeEntity, UpdateEntity): 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 a66b77ed94b..b64b76534be 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -288,8 +288,7 @@ def get_model_name(info: dict[str, Any]) -> str: def get_rpc_channel_name(device: RpcDevice, key: str) -> str: """Get name based on device and channel name.""" key = key.replace("emdata", "em") - if device.config.get("switch:0"): - key = key.replace("input", "switch") + key = key.replace("em1data", "em1") device_name = device.name entity_name: str | None = None if key in device.config: @@ -298,6 +297,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/sia/hub.py b/homeassistant/components/sia/hub.py index 859841d3bea..9ba7a19a9be 100644 --- a/homeassistant/components/sia/hub.py +++ b/homeassistant/components/sia/hub.py @@ -28,7 +28,6 @@ from .utils import get_event_data_from_sia_event _LOGGER = logging.getLogger(__name__) DEFAULT_TIMEBAND = (80, 40) -IGNORED_TIMEBAND = (3600, 1800) class SIAHub: @@ -100,7 +99,7 @@ class SIAHub: SIAAccount( account_id=a[CONF_ACCOUNT], key=a.get(CONF_ENCRYPTION_KEY), - allowed_timeband=IGNORED_TIMEBAND + allowed_timeband=None if a[CONF_IGNORE_TIMESTAMPS] else DEFAULT_TIMEBAND, ) diff --git a/homeassistant/components/sighthound/manifest.json b/homeassistant/components/sighthound/manifest.json index 33080a9c1a2..d1bc97da7a8 100644 --- a/homeassistant/components/sighthound/manifest.json +++ b/homeassistant/components/sighthound/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/sighthound", "iot_class": "cloud_polling", "loggers": ["simplehound"], - "requirements": ["Pillow==10.0.0", "simplehound==0.3"] + "requirements": ["Pillow==10.0.1", "simplehound==0.3"] } diff --git a/homeassistant/components/simplepush/config_flow.py b/homeassistant/components/simplepush/config_flow.py index 702be4391e4..d87f6fa1913 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 25f53a9617c..5b792072f44 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/simplisafe/binary_sensor.py b/homeassistant/components/simplisafe/binary_sensor.py index 34c0ea5ea95..d9384b948ed 100644 --- a/homeassistant/components/simplisafe/binary_sensor.py +++ b/homeassistant/components/simplisafe/binary_sensor.py @@ -19,12 +19,18 @@ from .const import DOMAIN, LOGGER SUPPORTED_BATTERY_SENSOR_TYPES = [ DeviceTypes.CARBON_MONOXIDE, + DeviceTypes.DOORBELL, DeviceTypes.ENTRY, DeviceTypes.GLASS_BREAK, + DeviceTypes.KEYCHAIN, DeviceTypes.KEYPAD, DeviceTypes.LEAK, + DeviceTypes.LOCK, DeviceTypes.LOCK_KEYPAD, DeviceTypes.MOTION, + DeviceTypes.MOTION_V2, + DeviceTypes.PANIC_BUTTON, + DeviceTypes.REMOTE, DeviceTypes.SIREN, DeviceTypes.SMOKE, DeviceTypes.SMOKE_AND_CARBON_MONOXIDE, @@ -37,6 +43,7 @@ TRIGGERED_SENSOR_TYPES = { DeviceTypes.GLASS_BREAK: BinarySensorDeviceClass.SAFETY, DeviceTypes.LEAK: BinarySensorDeviceClass.MOISTURE, DeviceTypes.MOTION: BinarySensorDeviceClass.MOTION, + DeviceTypes.MOTION_V2: BinarySensorDeviceClass.MOTION, DeviceTypes.SIREN: BinarySensorDeviceClass.SAFETY, DeviceTypes.SMOKE: BinarySensorDeviceClass.SMOKE, # Although this sensor can technically apply to both smoke and carbon, we use the diff --git a/homeassistant/components/siren/__init__.py b/homeassistant/components/siren/__init__.py index a8907ba3b68..ac02201b928 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 3daf4fc52b2..00000000000 --- 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 ec0993e290b..ccc1fbb6643 100644 --- a/homeassistant/components/slack/const.py +++ b/homeassistant/components/slack/const.py @@ -10,6 +10,7 @@ ATTR_SNOOZE = "snooze_endtime" 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 2bd3476cbbe..1b35db6f061 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 498eddffa3d..deba0796750 100644 --- a/homeassistant/components/slack/notify.py +++ b/homeassistant/components/slack/notify.py @@ -30,6 +30,7 @@ from .const import ( ATTR_FILE, ATTR_PASSWORD, ATTR_PATH, + ATTR_THREAD_TS, ATTR_URL, ATTR_USERNAME, CONF_DEFAULT_CHANNEL, @@ -50,7 +51,10 @@ FILE_URL_SCHEMA = vol.Schema( ) 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 @@ DATA_TEXT_ONLY_SCHEMA = vol.Schema( 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 @@ class SlackNotificationService(BaseNotificationService): 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 @@ class SlackNotificationService(BaseNotificationService): 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 @@ class SlackNotificationService(BaseNotificationService): targets: list[str], message: str, title: str | None, + thread_ts: str | None, *, username: str | None = None, password: str | None = None, @@ -205,6 +215,9 @@ class SlackNotificationService(BaseNotificationService): "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 @@ class SlackNotificationService(BaseNotificationService): targets: list[str], message: str, title: str | None, + thread_ts: str | None, *, username: str | None = None, icon: str | None = None, @@ -238,6 +252,9 @@ class SlackNotificationService(BaseNotificationService): 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 @@ class SlackNotificationService(BaseNotificationService): 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 @@ class SlackNotificationService(BaseNotificationService): 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 dbcc1931e58..11ed720b51c 100644 --- a/homeassistant/components/sma/sensor.py +++ b/homeassistant/components/sma/sensor.py @@ -8,10 +8,22 @@ import pysma 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 homeassistant.helpers.update_coordinator import ( 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 @@ class SMAsensor(CoordinatorEntity, SensorEntity): 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) - 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 description is not None: + self.entity_description = description + else: + self._attr_name = pysma_sensor.name - 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._sensor = pysma_sensor + + 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 @@ class SMAsensor(CoordinatorEntity, SensorEntity): @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 71bbaa472ae..ed09b51ff25 100644 --- a/homeassistant/components/smappee/binary_sensor.py +++ b/homeassistant/components/smappee/binary_sensor.py @@ -15,6 +15,23 @@ from .const import DOMAIN 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 @@ class SmappeeAppliance(BinarySensorEntity): 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 4228f57ea46..82bc60936b3 100644 --- a/homeassistant/components/smappee/sensor.py +++ b/homeassistant/components/smappee/sensor.py @@ -341,6 +341,13 @@ class SmappeeSensor(SensorEntity): 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 @@ class SmappeeSensor(SensorEntity): 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 1928e717f22..238e41af8ff 100644 --- a/homeassistant/components/smappee/switch.py +++ b/homeassistant/components/smappee/switch.py @@ -74,10 +74,17 @@ class SmappeeActuator(SwitchEntity): 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 @@ class SmappeeActuator(SwitchEntity): 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 7552f2c0697..f54da815b26 100644 --- a/homeassistant/components/smart_meter_texas/sensor.py +++ b/homeassistant/components/smart_meter_texas/sensor.py @@ -47,54 +47,36 @@ 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.""" + await super().async_added_to_hass() self.async_on_remove(self.coordinator.async_add_listener(self._state_update)) # If the background update finished before @@ -104,5 +86,5 @@ class SmartMeterTexasSensor(CoordinatorEntity, RestoreEntity, SensorEntity): 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 22856bdb05b..cdf04be29f3 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -429,6 +429,17 @@ class SmartThingsEntity(Entity): """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 @@ class SmartThingsEntity(Entity): """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 d0ffd0ac29d..25f9fa224ff 100644 --- a/homeassistant/components/smartthings/binary_sensor.py +++ b/homeassistant/components/smartthings/binary_sensor.py @@ -73,28 +73,12 @@ class SmartThingsBinarySensor(SmartThingsEntity, BinarySensorEntity): """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 5d7e29c1312..83522c61794 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 @@ class SmartThingsCover(SmartThingsEntity, CoverEntity): ): 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 @@ class SmartThingsCover(SmartThingsEntity, CoverEntity): 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 @@ class SmartThingsCover(SmartThingsEntity, CoverEntity): 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 7278f350dc1..ebf80e22909 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 @@ class SmartThingsFan(SmartThingsEntity, FanEntity): 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 37237323d1c..58623e08394 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 @@ class SmartThingsLight(SmartThingsEntity, LightEntity): """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 @@ class SmartThingsLight(SmartThingsEntity, LightEntity): 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 9ccda5fd5e6..ffdb900237e 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 @@ class SmartThingsScene(Scene): "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 823ca793972..18016a88d29 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -629,44 +629,30 @@ class SmartThingsSensor(SmartThingsEntity, SensorEntity): 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 @@ class SmartThingsThreeAxisSensor(SmartThingsEntity, SensorEntity): """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 @@ class SmartThingsPowerConsumptionSensor(SmartThingsEntity, SensorEntity): """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 @@ class SmartThingsPowerConsumptionSensor(SmartThingsEntity, SensorEntity): 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 a1159bcc0ef..99037cd623c 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 @@ class SmartTubReminder(SmartTubEntity, BinarySensorEntity): 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 a938bde6fd1..b2d4fbf17c4 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 @@ class SmartTubThermostat(SmartTubEntity, ClimateEntity): """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 7f2a739c26e..6e6cb00a7d3 100644 --- a/homeassistant/components/smarttub/entity.py +++ b/homeassistant/components/smarttub/entity.py @@ -25,27 +25,14 @@ class SmartTubEntity(CoordinatorEntity): 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 @@ class SmartTubEntity(CoordinatorEntity): 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 f7e229449e0..d89cdba3367 100644 --- a/homeassistant/components/smarttub/light.py +++ b/homeassistant/components/smarttub/light.py @@ -53,23 +53,15 @@ class SmartTubLight(SmartTubEntity, LightEntity): """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 e105963bc01..aeeca46aaa9 100644 --- a/homeassistant/components/smarttub/switch.py +++ b/homeassistant/components/smarttub/switch.py @@ -38,17 +38,13 @@ class SmartTubPump(SmartTubEntity, SwitchEntity): 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 824a95e36b1..a606b83896f 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.core import HomeAssistant 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 @@ from .const import ( SIGNAL_COORDINATOR, SMS_GATEWAY, ) +from .coordinator import NetworkCoordinator, SignalCoordinator from .gateway import create_sms_gateway _LOGGER = logging.getLogger(__name__) @@ -45,8 +41,6 @@ CONFIG_SCHEMA = vol.Schema( 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 00000000000..fd212fce4f2 --- /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 abf54efdd9d..5e7fb41c212 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 00000000000..1fa077a24fb --- /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 7037c239db3..6836a0b9f6b 100644 --- a/homeassistant/components/smtp/notify.py +++ b/homeassistant/components/smtp/notify.py @@ -28,6 +28,7 @@ from homeassistant.const import ( CONF_TIMEOUT, CONF_USERNAME, CONF_VERIFY_SSL, + Platform, ) from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv @@ -36,26 +37,26 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType import homeassistant.util.dt as dt_util from homeassistant.util.ssl import client_context -from . import DOMAIN, PLATFORMS +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, +) + +PLATFORMS = [Platform.NOTIFY] _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" - -DEFAULT_HOST = "localhost" -DEFAULT_PORT = 587 -DEFAULT_TIMEOUT = 5 -DEFAULT_DEBUG = False -DEFAULT_ENCRYPTION = "starttls" - -ENCRYPTION_OPTIONS = ["tls", "starttls", "none"] - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_RECIPIENT): vol.All(cv.ensure_list, [vol.Email()]), diff --git a/homeassistant/components/snapcast/media_player.py b/homeassistant/components/snapcast/media_player.py index 9dadae2e3e2..f0b6eccf8b4 100644 --- a/homeassistant/components/snapcast/media_player.py +++ b/homeassistant/components/snapcast/media_player.py @@ -160,7 +160,7 @@ class SnapcastGroupDevice(MediaPlayerEntity): 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 @@ class SnapcastGroupDevice(MediaPlayerEntity): 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 @@ class SnapcastClientDevice(MediaPlayerEntity): """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 @@ class SnapcastClientDevice(MediaPlayerEntity): 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 c5b3e5b5b69..5cb80cb4189 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 e1ea7960086..f2c073c6918 100644 --- a/homeassistant/components/solaredge/sensor.py +++ b/homeassistant/components/solaredge/sensor.py @@ -353,7 +353,7 @@ class SolarEdgeDetailsSensor(SolarEdgeSensorEntity): """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 e0ab838922b..95cf5cc4567 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 00000000000..d363256f355 --- /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/sonarr/entity.py b/homeassistant/components/sonarr/entity.py index d73b9d852c8..6231ca3903a 100644 --- a/homeassistant/components/sonarr/entity.py +++ b/homeassistant/components/sonarr/entity.py @@ -24,15 +24,11 @@ class SonarrEntity(CoordinatorEntity[SonarrDataUpdateCoordinator[SonarrDataT]]): 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 2d2c5892636..79fab9a2651 100644 --- a/homeassistant/components/songpal/media_player.py +++ b/homeassistant/components/songpal/media_player.py @@ -110,14 +110,14 @@ class SongpalEntity(MediaPlayerEntity): 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 @@ class SongpalEntity(MediaPlayerEntity): 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 @@ class SongpalEntity(MediaPlayerEntity): 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 @@ class SongpalEntity(MediaPlayerEntity): 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 @@ class SongpalEntity(MediaPlayerEntity): 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 @@ class SongpalEntity(MediaPlayerEntity): 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 @@ class SongpalEntity(MediaPlayerEntity): _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 @@ class SongpalEntity(MediaPlayerEntity): @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 @@ class SongpalEntity(MediaPlayerEntity): """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 63e5a551745..fa5c0dd7095 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 @@ class SoundTouchMediaPlayer(MediaPlayerEntity): """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 b78703666bc..ace352b2ba0 100644 --- a/homeassistant/components/spc/alarm_control_panel.py +++ b/homeassistant/components/spc/alarm_control_panel.py @@ -64,6 +64,7 @@ class SpcAlarm(alarm.AlarmControlPanelEntity): """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 @@ class SpcAlarm(alarm.AlarmControlPanelEntity): """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 c4aaefdd518..a43551567e6 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 @@ class SpcBinarySensor(BinarySensorEntity): """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 5bcf178f396..af41c400e0b 100644 --- a/homeassistant/components/speedtestdotnet/sensor.py +++ b/homeassistant/components/speedtestdotnet/sensor.py @@ -6,6 +6,7 @@ from dataclasses import dataclass from typing import Any, cast from homeassistant.components.sensor import ( + SensorDeviceClass, SensorEntity, SensorEntityDescription, SensorStateClass, @@ -15,7 +16,6 @@ from homeassistant.const import UnitOfDataRate, UnitOfTime 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 @@ SENSOR_TYPES: tuple[SpeedtestSensorEntityDescription, ...] = ( 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 @@ SENSOR_TYPES: tuple[SpeedtestSensorEntityDescription, ...] = ( 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 @@ class SpeedtestSensor( 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 7ca1533744c..84f2bc102e3 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 44de8fc6923..7424807c804 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/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index c77126e4377..03457c6a5c0 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 @@ class SqueezeBoxEntity(MediaPlayerEntity): 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 @@ class SqueezeBoxEntity(MediaPlayerEntity): 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/const.py b/homeassistant/components/srp_energy/const.py index 5128dc48b35..bace71aca55 100644 --- a/homeassistant/components/srp_energy/const.py +++ b/homeassistant/components/srp_energy/const.py @@ -11,6 +11,3 @@ CONF_IS_TOU = "is_tou" PHOENIX_TIME_ZONE = "America/Phoenix" MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=1440) - -SENSOR_NAME = "Energy Usage" -SENSOR_TYPE = "usage" diff --git a/homeassistant/components/srp_energy/sensor.py b/homeassistant/components/srp_energy/sensor.py index a7f0f97b636..37aacf4ff25 100644 --- a/homeassistant/components/srp_energy/sensor.py +++ b/homeassistant/components/srp_energy/sensor.py @@ -9,11 +9,12 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfEnergy 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 . import SRPEnergyDataUpdateCoordinator -from .const import DEFAULT_NAME, DOMAIN, SENSOR_NAME +from .const import DOMAIN async def async_setup_entry( @@ -29,10 +30,11 @@ class SrpEntity(CoordinatorEntity[SRPEnergyDataUpdateCoordinator], SensorEntity) """Implementation of a Srp Energy Usage sensor.""" _attr_attribution = "Powered by SRP Energy" - _attr_icon = "mdi:flash" _attr_native_unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR _attr_device_class = SensorDeviceClass.ENERGY _attr_state_class = SensorStateClass.TOTAL_INCREASING + _attr_has_entity_name = True + _attr_translation_key = "energy_usage" def __init__( self, coordinator: SRPEnergyDataUpdateCoordinator, config_entry: ConfigEntry @@ -40,12 +42,11 @@ class SrpEntity(CoordinatorEntity[SRPEnergyDataUpdateCoordinator], SensorEntity) """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_device_info = DeviceInfo( + identifiers={(DOMAIN, config_entry.entry_id)}, + name="SRP Energy", + entry_type=DeviceEntryType.SERVICE, + ) @property def native_value(self) -> float: diff --git a/homeassistant/components/srp_energy/strings.json b/homeassistant/components/srp_energy/strings.json index 3dddd961194..fd963411198 100644 --- a/homeassistant/components/srp_energy/strings.json +++ b/homeassistant/components/srp_energy/strings.json @@ -19,5 +19,12 @@ "abort": { "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" } + }, + "entity": { + "sensor": { + "energy_usage": { + "name": "Energy usage" + } + } } } diff --git a/homeassistant/components/ssdp/__init__.py b/homeassistant/components/ssdp/__init__.py index 3be5475a71a..ded663af897 100644 --- a/homeassistant/components/ssdp/__init__.py +++ b/homeassistant/components/ssdp/__init__.py @@ -23,13 +23,7 @@ from async_upnp_client.const import ( 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 @@ SSDP_SCANNER = "scanner" 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 @@ class Server: 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 a6eb95933b4..21f0036aabd 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.36.1"] } diff --git a/homeassistant/components/starline/entity.py b/homeassistant/components/starline/entity.py index 7eee5e7a7f8..27be5e2aace 100644 --- a/homeassistant/components/starline/entity.py +++ b/homeassistant/components/starline/entity.py @@ -21,6 +21,8 @@ class StarlineEntity(Entity): 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 @@ class StarlineEntity(Entity): """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 b254fa8133f..ebe27e29e8c 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 @@ class StarlineSwitch(StarlineEntity, SwitchEntity): 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 691ba262ee2..837d11eca7c 100644 --- a/homeassistant/components/stream/__init__.py +++ b/homeassistant/components/stream/__init__.py @@ -29,7 +29,7 @@ from typing import TYPE_CHECKING, Any, Final, cast import voluptuous as vol from yarl import URL -from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.const import EVENT_HOMEASSISTANT_STOP, EVENT_LOGGING_CHANGED from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv @@ -188,36 +188,32 @@ CONFIG_SCHEMA = vol.Schema( ) -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 +254,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ]: 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 96474ceb7eb..37158aa5fe3 100644 --- a/homeassistant/components/stream/manifest.json +++ b/homeassistant/components/stream/manifest.json @@ -7,5 +7,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["PyTurboJPEG==1.7.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 679f9b29e41..b1730a09357 100644 --- a/homeassistant/components/stt/__init__.py +++ b/homeassistant/components/stt/__init__.py @@ -40,12 +40,11 @@ from .const import ( ) 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 f14eed467db..862f59d5f6d 100644 --- a/homeassistant/components/stt/legacy.py +++ b/homeassistant/components/stt/legacy.py @@ -3,7 +3,6 @@ from __future__ import annotations 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 @@ from .const import ( AudioCodecs, AudioFormats, AudioSampleRates, - SpeechResultState, ) +from .models import SpeechMetadata, SpeechResult _LOGGER = logging.getLogger(__name__) @@ -88,32 +87,6 @@ def async_setup_legacy( ] -@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 00000000000..45322e2da07 --- /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 5e6db32d4ad..78625192e4a 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 de1c545739f..feb68d76f6a 100644 --- a/homeassistant/components/sun/__init__.py +++ b/homeassistant/components/sun/__init__.py @@ -16,10 +16,8 @@ from homeassistant.const import ( ) from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback from homeassistant.helpers import config_validation as cv, event +from homeassistant.helpers.dispatcher import async_dispatcher_send 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, @@ -27,7 +25,7 @@ from homeassistant.helpers.sun import ( from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util -from .const import DOMAIN +from .const import DOMAIN, SIGNAL_EVENTS_CHANGED, SIGNAL_POSITION_CHANGED _LOGGER = logging.getLogger(__name__) @@ -97,9 +95,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 +114,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 +152,12 @@ class Sun(Entity): 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 @@ -271,6 +286,7 @@ class Sun(Entity): if self._update_sun_position_listener: self._update_sun_position_listener() self.update_sun_position() + async_dispatcher_send(self.hass, SIGNAL_EVENTS_CHANGED) # Set timer for the next solar event self._update_events_listener = event.async_track_point_in_utc_time( @@ -298,6 +314,8 @@ class Sun(Entity): ) self.async_write_ha_state() + async_dispatcher_send(self.hass, SIGNAL_POSITION_CHANGED) + # Next update as per the current phase assert self.phase delta = _PHASE_UPDATES[self.phase] diff --git a/homeassistant/components/sun/const.py b/homeassistant/components/sun/const.py index f567c77e62a..245f8ca1d58 100644 --- a/homeassistant/components/sun/const.py +++ b/homeassistant/components/sun/const.py @@ -4,3 +4,6 @@ from typing import Final DOMAIN: Final = "sun" DEFAULT_NAME: Final = "Sun" + +SIGNAL_POSITION_CHANGED = f"{DOMAIN}_position_changed" +SIGNAL_EVENTS_CHANGED = f"{DOMAIN}_events_changed" diff --git a/homeassistant/components/sun/recorder.py b/homeassistant/components/sun/recorder.py deleted file mode 100644 index 710d7ff4559..00000000000 --- 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/sun/sensor.py b/homeassistant/components/sun/sensor.py index 6eccbc93d37..f83564bbac3 100644 --- a/homeassistant/components/sun/sensor.py +++ b/homeassistant/components/sun/sensor.py @@ -16,11 +16,12 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import DEGREE, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from . import Sun -from .const import DOMAIN +from .const import DOMAIN, SIGNAL_EVENTS_CHANGED, SIGNAL_POSITION_CHANGED ENTITY_ID_SENSOR_FORMAT = SENSOR_DOMAIN + ".sun_{}" @@ -30,6 +31,7 @@ class SunEntityDescriptionMixin: """Mixin for required Sun base description keys.""" value_fn: Callable[[Sun], StateType | datetime] + signal: str @dataclass @@ -44,6 +46,7 @@ SENSOR_TYPES: tuple[SunSensorEntityDescription, ...] = ( translation_key="next_dawn", icon="mdi:sun-clock", value_fn=lambda data: data.next_dawn, + signal=SIGNAL_EVENTS_CHANGED, ), SunSensorEntityDescription( key="next_dusk", @@ -51,6 +54,7 @@ SENSOR_TYPES: tuple[SunSensorEntityDescription, ...] = ( translation_key="next_dusk", icon="mdi:sun-clock", value_fn=lambda data: data.next_dusk, + signal=SIGNAL_EVENTS_CHANGED, ), SunSensorEntityDescription( key="next_midnight", @@ -58,6 +62,7 @@ SENSOR_TYPES: tuple[SunSensorEntityDescription, ...] = ( translation_key="next_midnight", icon="mdi:sun-clock", value_fn=lambda data: data.next_midnight, + signal=SIGNAL_EVENTS_CHANGED, ), SunSensorEntityDescription( key="next_noon", @@ -65,6 +70,7 @@ SENSOR_TYPES: tuple[SunSensorEntityDescription, ...] = ( translation_key="next_noon", icon="mdi:sun-clock", value_fn=lambda data: data.next_noon, + signal=SIGNAL_EVENTS_CHANGED, ), SunSensorEntityDescription( key="next_rising", @@ -72,6 +78,7 @@ SENSOR_TYPES: tuple[SunSensorEntityDescription, ...] = ( translation_key="next_rising", icon="mdi:sun-clock", value_fn=lambda data: data.next_rising, + signal=SIGNAL_EVENTS_CHANGED, ), SunSensorEntityDescription( key="next_setting", @@ -79,6 +86,7 @@ SENSOR_TYPES: tuple[SunSensorEntityDescription, ...] = ( translation_key="next_setting", icon="mdi:sun-clock", value_fn=lambda data: data.next_setting, + signal=SIGNAL_EVENTS_CHANGED, ), SunSensorEntityDescription( key="solar_elevation", @@ -88,6 +96,7 @@ SENSOR_TYPES: tuple[SunSensorEntityDescription, ...] = ( value_fn=lambda data: data.solar_elevation, entity_registry_enabled_default=False, native_unit_of_measurement=DEGREE, + signal=SIGNAL_POSITION_CHANGED, ), SunSensorEntityDescription( key="solar_azimuth", @@ -97,6 +106,7 @@ SENSOR_TYPES: tuple[SunSensorEntityDescription, ...] = ( value_fn=lambda data: data.solar_azimuth, entity_registry_enabled_default=False, native_unit_of_measurement=DEGREE, + signal=SIGNAL_POSITION_CHANGED, ), ) @@ -117,6 +127,7 @@ class SunSensor(SensorEntity): """Representation of a Sun Sensor.""" _attr_has_entity_name = True + _attr_should_poll = False _attr_entity_category = EntityCategory.DIAGNOSTIC entity_description: SunSensorEntityDescription @@ -128,7 +139,6 @@ class SunSensor(SensorEntity): self.entity_id = ENTITY_ID_SENSOR_FORMAT.format(entity_description.key) self._attr_unique_id = f"{entry_id}-{entity_description.key}" self.sun = sun - self._attr_device_info = DeviceInfo( name="Sun", identifiers={(DOMAIN, entry_id)}, @@ -138,5 +148,15 @@ class SunSensor(SensorEntity): @property def native_value(self) -> StateType | datetime: """Return value of sensor.""" - state = self.entity_description.value_fn(self.sun) - return state + return self.entity_description.value_fn(self.sun) + + async def async_added_to_hass(self) -> None: + """Register signal listener when added to hass.""" + await super().async_added_to_hass() + self.async_on_remove( + async_dispatcher_connect( + self.hass, + self.entity_description.signal, + self.async_write_ha_state, + ) + ) diff --git a/homeassistant/components/supla/cover.py b/homeassistant/components/supla/cover.py index 53e57fe1854..cc3a5a4ed0c 100644 --- a/homeassistant/components/supla/cover.py +++ b/homeassistant/components/supla/cover.py @@ -95,6 +95,8 @@ class SuplaCoverEntity(SuplaEntity, CoverEntity): 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 @@ class SuplaDoorEntity(SuplaEntity, CoverEntity): 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 2259a450559..e835a2f4aca 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", @@ -39,5 +39,5 @@ "documentation": "https://www.home-assistant.io/integrations/switchbot", "iot_class": "local_push", "loggers": ["switchbot"], - "requirements": ["PySwitchbot==0.39.1"] + "requirements": ["PySwitchbot==0.40.1"] } diff --git a/homeassistant/components/switchbot_cloud/__init__.py b/homeassistant/components/switchbot_cloud/__init__.py new file mode 100644 index 00000000000..cf711fcc431 --- /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 00000000000..5c99567968c --- /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 00000000000..ef69c9c1d02 --- /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 00000000000..92099ccde43 --- /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 00000000000..5d0e2ff09c3 --- /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 00000000000..0451217ca5f --- /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 00000000000..11e92e6dfa3 --- /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 00000000000..c63b1713b8d --- /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 0551ae29d2c..c88de91cae0 100644 --- a/homeassistant/components/syncthing/sensor.py +++ b/homeassistant/components/syncthing/sensor.py @@ -94,19 +94,17 @@ class FolderSensor(SensorEntity): 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 @@ class FolderSensor(SensorEntity): """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 c2ad159fb21..f651556bddb 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 @@ class SyncThruMainSensor(SyncThruSensor): "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 d50540f7b42..d13f5bcbdde 100644 --- a/homeassistant/components/system_bridge/__init__.py +++ b/homeassistant/components/system_bridge/__init__.py @@ -20,7 +20,9 @@ from homeassistant.config_entries import ConfigEntry 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.const import ( ) 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 @@ _LOGGER = logging.getLogger(__name__) 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 async_setup_entry( 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/notify.py b/homeassistant/components/system_bridge/notify.py new file mode 100644 index 00000000000..1ad071bf78f --- /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 ab271ec676c..fab2b7ee291 100644 --- a/homeassistant/components/system_log/__init__.py +++ b/homeassistant/components/system_log/__init__.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections import OrderedDict, deque import logging import re +import sys import traceback from typing import Any, cast @@ -59,31 +60,65 @@ SERVICE_WRITE_SCHEMA = vol.Schema( 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 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 - # 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]) # Ok, we don't know what this is return (record.pathname, record.lineno) @@ -217,11 +252,7 @@ class LogErrorHandler(logging.Handler): 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 36a2ab671c9..1193638c10e 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 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): 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 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): """ 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 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): 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 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): """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 cfc9e5b1e6e..532d784b190 100644 --- a/homeassistant/components/tado/entity.py +++ b/homeassistant/components/tado/entity.py @@ -17,18 +17,14 @@ class TadoDeviceEntity(Entity): 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 @@ class TadoHomeEntity(Entity): 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 f7ba1682e18..c665cc3c592 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 @@ HOME_SENSORS = [ 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 @@ ZONE_SENSORS = { } -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 6d17c85c981..b7e68bbb100 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 @@ class TadoWaterHeater(TadoZoneEntity, WaterHeaterEntity): 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 @@ class TadoWaterHeater(TadoZoneEntity, WaterHeaterEntity): ) 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 @@ class TadoWaterHeater(TadoZoneEntity, WaterHeaterEntity): """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/tankerkoenig/__init__.py b/homeassistant/components/tankerkoenig/__init__.py index 39ae0c2fc16..ac93154388a 100644 --- a/homeassistant/components/tankerkoenig/__init__.py +++ b/homeassistant/components/tankerkoenig/__init__.py @@ -1,26 +1,18 @@ """Ask tankerkoenig.de for petrol price information.""" from __future__ import annotations -from datetime import timedelta import logging -from math import ceil -import pytankerkoenig from requests.exceptions import RequestException from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_ID, CONF_API_KEY, CONF_SHOW_ON_MAP, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryNotReady import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, - UpdateFailed, -) -from .const import CONF_FUEL_TYPES, CONF_STATIONS, DEFAULT_SCAN_INTERVAL, DOMAIN +from .const import DEFAULT_SCAN_INTERVAL, DOMAIN +from .coordinator import TankerkoenigDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -70,117 +62,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) - - -class TankerkoenigDataUpdateCoordinator(DataUpdateCoordinator): - """Get the latest data from the API.""" - - def __init__( - self, - hass: HomeAssistant, - entry: ConfigEntry, - logger: logging.Logger, - name: str, - update_interval: int, - ) -> None: - """Initialize the data object.""" - - super().__init__( - hass=hass, - logger=logger, - name=name, - update_interval=timedelta(minutes=update_interval), - ) - - self._api_key: str = entry.data[CONF_API_KEY] - self._selected_stations: list[str] = entry.data[CONF_STATIONS] - self.stations: dict[str, dict] = {} - self.fuel_types: list[str] = entry.data[CONF_FUEL_TYPES] - self.show_on_map: bool = entry.options[CONF_SHOW_ON_MAP] - - def setup(self) -> bool: - """Set up the tankerkoenig API.""" - for station_id in self._selected_stations: - try: - station_data = pytankerkoenig.getStationData(self._api_key, station_id) - except pytankerkoenig.customException as err: - if any(x in str(err).lower() for x in ("api-key", "apikey")): - raise ConfigEntryAuthFailed(err) from err - station_data = { - "ok": False, - "message": err, - "exception": True, - } - - if not station_data["ok"]: - _LOGGER.error( - "Error when adding station %s:\n %s", - station_id, - station_data["message"], - ) - continue - self.add_station(station_data["station"]) - if len(self.stations) > 10: - _LOGGER.warning( - "Found more than 10 stations to check. " - "This might invalidate your api-key on the long run. " - "Try using a smaller radius" - ) - return True - - async def _async_update_data(self) -> dict: - """Get the latest data from tankerkoenig.de.""" - _LOGGER.debug("Fetching new data from tankerkoenig.de") - station_ids = list(self.stations) - - prices = {} - - # The API seems to only return at most 10 results, so split the list in chunks of 10 - # and merge it together. - for index in range(ceil(len(station_ids) / 10)): - data = await self.hass.async_add_executor_job( - pytankerkoenig.getPriceList, - self._api_key, - station_ids[index * 10 : (index + 1) * 10], - ) - - _LOGGER.debug("Received data: %s", data) - if not data["ok"]: - raise UpdateFailed(data["message"]) - if "prices" not in data: - raise UpdateFailed( - "Did not receive price information from tankerkoenig.de" - ) - prices.update(data["prices"]) - return prices - - def add_station(self, station: dict): - """Add fuel station to the entity list.""" - station_id = station["id"] - if station_id in self.stations: - _LOGGER.warning( - "Sensor for station with id %s was already created", station_id - ) - return - - self.stations[station_id] = station - _LOGGER.debug("add_station called for station: %s", station) - - -class TankerkoenigCoordinatorEntity(CoordinatorEntity): - """Tankerkoenig base entity.""" - - _attr_has_entity_name = True - - def __init__( - self, coordinator: TankerkoenigDataUpdateCoordinator, station: dict - ) -> None: - """Initialize the Tankerkoenig base entity.""" - super().__init__(coordinator) - self._attr_device_info = DeviceInfo( - identifiers={(ATTR_ID, station["id"])}, - name=f"{station['brand']} {station['street']} {station['houseNumber']}", - model=station["brand"], - configuration_url="https://www.tankerkoenig.de", - entry_type=DeviceEntryType.SERVICE, - ) diff --git a/homeassistant/components/tankerkoenig/binary_sensor.py b/homeassistant/components/tankerkoenig/binary_sensor.py index a6a79fd2d92..2cf8869fcae 100644 --- a/homeassistant/components/tankerkoenig/binary_sensor.py +++ b/homeassistant/components/tankerkoenig/binary_sensor.py @@ -12,8 +12,9 @@ from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import TankerkoenigCoordinatorEntity, TankerkoenigDataUpdateCoordinator from .const import DOMAIN +from .coordinator import TankerkoenigDataUpdateCoordinator +from .entity import TankerkoenigCoordinatorEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/tankerkoenig/coordinator.py b/homeassistant/components/tankerkoenig/coordinator.py new file mode 100644 index 00000000000..536875f5733 --- /dev/null +++ b/homeassistant/components/tankerkoenig/coordinator.py @@ -0,0 +1,113 @@ +"""The Tankerkoenig update coordinator.""" +from __future__ import annotations + +from datetime import timedelta +import logging +from math import ceil + +import pytankerkoenig + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, CONF_SHOW_ON_MAP +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import CONF_FUEL_TYPES, CONF_STATIONS + +_LOGGER = logging.getLogger(__name__) + + +class TankerkoenigDataUpdateCoordinator(DataUpdateCoordinator): + """Get the latest data from the API.""" + + def __init__( + self, + hass: HomeAssistant, + entry: ConfigEntry, + logger: logging.Logger, + name: str, + update_interval: int, + ) -> None: + """Initialize the data object.""" + + super().__init__( + hass=hass, + logger=logger, + name=name, + update_interval=timedelta(minutes=update_interval), + ) + + self._api_key: str = entry.data[CONF_API_KEY] + self._selected_stations: list[str] = entry.data[CONF_STATIONS] + self.stations: dict[str, dict] = {} + self.fuel_types: list[str] = entry.data[CONF_FUEL_TYPES] + self.show_on_map: bool = entry.options[CONF_SHOW_ON_MAP] + + def setup(self) -> bool: + """Set up the tankerkoenig API.""" + for station_id in self._selected_stations: + try: + station_data = pytankerkoenig.getStationData(self._api_key, station_id) + except pytankerkoenig.customException as err: + if any(x in str(err).lower() for x in ("api-key", "apikey")): + raise ConfigEntryAuthFailed(err) from err + station_data = { + "ok": False, + "message": err, + "exception": True, + } + + if not station_data["ok"]: + _LOGGER.error( + "Error when adding station %s:\n %s", + station_id, + station_data["message"], + ) + continue + self.add_station(station_data["station"]) + if len(self.stations) > 10: + _LOGGER.warning( + "Found more than 10 stations to check. " + "This might invalidate your api-key on the long run. " + "Try using a smaller radius" + ) + return True + + async def _async_update_data(self) -> dict: + """Get the latest data from tankerkoenig.de.""" + _LOGGER.debug("Fetching new data from tankerkoenig.de") + station_ids = list(self.stations) + + prices = {} + + # The API seems to only return at most 10 results, so split the list in chunks of 10 + # and merge it together. + for index in range(ceil(len(station_ids) / 10)): + data = await self.hass.async_add_executor_job( + pytankerkoenig.getPriceList, + self._api_key, + station_ids[index * 10 : (index + 1) * 10], + ) + + _LOGGER.debug("Received data: %s", data) + if not data["ok"]: + raise UpdateFailed(data["message"]) + if "prices" not in data: + raise UpdateFailed( + "Did not receive price information from tankerkoenig.de" + ) + prices.update(data["prices"]) + return prices + + def add_station(self, station: dict): + """Add fuel station to the entity list.""" + station_id = station["id"] + if station_id in self.stations: + _LOGGER.warning( + "Sensor for station with id %s was already created", station_id + ) + return + + self.stations[station_id] = station + _LOGGER.debug("add_station called for station: %s", station) diff --git a/homeassistant/components/tankerkoenig/entity.py b/homeassistant/components/tankerkoenig/entity.py new file mode 100644 index 00000000000..6fbd9057679 --- /dev/null +++ b/homeassistant/components/tankerkoenig/entity.py @@ -0,0 +1,25 @@ +"""The tankerkoenig base entity.""" +from homeassistant.const import ATTR_ID +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .coordinator import TankerkoenigDataUpdateCoordinator + + +class TankerkoenigCoordinatorEntity(CoordinatorEntity): + """Tankerkoenig base entity.""" + + _attr_has_entity_name = True + + def __init__( + self, coordinator: TankerkoenigDataUpdateCoordinator, station: dict + ) -> None: + """Initialize the Tankerkoenig base entity.""" + super().__init__(coordinator) + self._attr_device_info = DeviceInfo( + identifiers={(ATTR_ID, station["id"])}, + name=f"{station['brand']} {station['street']} {station['houseNumber']}", + model=station["brand"], + configuration_url="https://www.tankerkoenig.de", + entry_type=DeviceEntryType.SERVICE, + ) diff --git a/homeassistant/components/tankerkoenig/sensor.py b/homeassistant/components/tankerkoenig/sensor.py index af21ac4b6d6..c309536cb9c 100644 --- a/homeassistant/components/tankerkoenig/sensor.py +++ b/homeassistant/components/tankerkoenig/sensor.py @@ -9,7 +9,6 @@ from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, CURRENCY_EURO from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import TankerkoenigCoordinatorEntity, TankerkoenigDataUpdateCoordinator from .const import ( ATTR_BRAND, ATTR_CITY, @@ -21,6 +20,8 @@ from .const import ( ATTRIBUTION, DOMAIN, ) +from .coordinator import TankerkoenigDataUpdateCoordinator +from .entity import TankerkoenigCoordinatorEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/tasmota/mixins.py b/homeassistant/components/tasmota/mixins.py index e99106d09e8..21030b8c14b 100644 --- a/homeassistant/components/tasmota/mixins.py +++ b/homeassistant/components/tasmota/mixins.py @@ -38,6 +38,9 @@ class TasmotaEntity(Entity): """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 @@ class TasmotaEntity(Entity): """(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 e718c0fdcf4..29d3f5c8c8a 100644 --- a/homeassistant/components/tasmota/sensor.py +++ b/homeassistant/components/tasmota/sensor.py @@ -274,6 +274,26 @@ class TasmotaSensor(TasmotaAvailability, TasmotaDiscoveryUpdate, SensorEntity): **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) @@ -288,58 +308,9 @@ class TasmotaSensor(TasmotaAvailability, TasmotaDiscoveryUpdate, SensorEntity): 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/template/__init__.py b/homeassistant/components/template/__init__.py index c4ba7081f5a..22919ac9e70 100644 --- a/homeassistant/components/template/__init__.py +++ b/homeassistant/components/template/__init__.py @@ -2,30 +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.script import Script 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_ACTION, CONF_TRIGGER, DOMAIN, PLATFORMS +from .const import CONF_TRIGGER, DOMAIN, PLATFORMS +from .coordinator import TriggerUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -121,83 +112,3 @@ async def _process_config(hass: HomeAssistant, hass_config: ConfigType) -> None: 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 - 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/alarm_control_panel.py b/homeassistant/components/template/alarm_control_panel.py index af2e432c61e..2cac5d74a7a 100644 --- a/homeassistant/components/template/alarm_control_panel.py +++ b/homeassistant/components/template/alarm_control_panel.py @@ -154,8 +154,8 @@ class AlarmControlPanelTemplate(TemplateEntity, AlarmControlPanelEntity): 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 @@ class AlarmControlPanelTemplate(TemplateEntity, AlarmControlPanelEntity): 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 @@ class AlarmControlPanelTemplate(TemplateEntity, AlarmControlPanelEntity): supported_features = ( supported_features | AlarmControlPanelEntityFeature.TRIGGER ) - - return supported_features + self._attr_supported_features = 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 - - @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 ca0ed583d86..427fe6221cd 100644 --- a/homeassistant/components/template/binary_sensor.py +++ b/homeassistant/components/template/binary_sensor.py @@ -14,7 +14,6 @@ from homeassistant.components.binary_sensor import ( DOMAIN as BINARY_SENSOR_DOMAIN, ENTITY_ID_FORMAT, PLATFORM_SCHEMA, - BinarySensorDeviceClass, BinarySensorEntity, ) from homeassistant.config_entries import ConfigEntry @@ -236,9 +235,7 @@ class BinarySensorTemplate(TemplateEntity, BinarySensorEntity, RestoreEntity): 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 @@ class BinarySensorTemplate(TemplateEntity, BinarySensorEntity, RestoreEntity): """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 54c82d88c74..3329f185f08 100644 --- a/homeassistant/components/template/config.py +++ b/homeassistant/components/template/config.py @@ -9,6 +9,7 @@ from homeassistant.components.image import DOMAIN as IMAGE_DOMAIN from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN from homeassistant.components.select import DOMAIN as SELECT_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN from homeassistant.config import async_log_exception, config_without_domain from homeassistant.const import CONF_BINARY_SENSORS, CONF_SENSORS, CONF_UNIQUE_ID from homeassistant.helpers import config_validation as cv @@ -21,6 +22,7 @@ from . import ( number as number_platform, select as select_platform, sensor as sensor_platform, + weather as weather_platform, ) from .const import CONF_ACTION, CONF_TRIGGER, DOMAIN @@ -55,6 +57,9 @@ CONFIG_SECTION_SCHEMA = vol.Schema( vol.Optional(IMAGE_DOMAIN): vol.All( cv.ensure_list, [image_platform.IMAGE_SCHEMA] ), + vol.Optional(WEATHER_DOMAIN): vol.All( + cv.ensure_list, [weather_platform.WEATHER_SCHEMA] + ), } ) diff --git a/homeassistant/components/template/coordinator.py b/homeassistant/components/template/coordinator.py new file mode 100644 index 00000000000..7f24fe731cc --- /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 3a8e536f7f5..5daa4531109 100644 --- a/homeassistant/components/template/cover.py +++ b/homeassistant/components/template/cover.py @@ -12,7 +12,6 @@ from homeassistant.components.cover import ( DEVICE_CLASSES_SCHEMA, ENTITY_ID_FORMAT, PLATFORM_SCHEMA, - CoverDeviceClass, CoverEntity, CoverEntityFeature, ) @@ -155,7 +154,7 @@ class CoverTemplate(TemplateEntity, CoverEntity): 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 @@ class CoverTemplate(TemplateEntity, CoverEntity): 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 @@ class CoverTemplate(TemplateEntity, CoverEntity): """ 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 c07c680887b..d39fa56775a 100644 --- a/homeassistant/components/template/fan.py +++ b/homeassistant/components/template/fan.py @@ -195,6 +195,8 @@ class TemplateFan(TemplateEntity, FanEntity): 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 @@ class TemplateFan(TemplateEntity, FanEntity): ", ".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 09f5054ed51..b3f276240b5 100644 --- a/homeassistant/components/template/light.py +++ b/homeassistant/components/template/light.py @@ -197,6 +197,12 @@ class LightTemplate(TemplateEntity, LightEntity): 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 @@ class LightTemplate(TemplateEntity, LightEntity): """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 @@ class LightTemplate(TemplateEntity, LightEntity): 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 d8c7127f0e6..de483971ac6 100644 --- a/homeassistant/components/template/lock.py +++ b/homeassistant/components/template/lock.py @@ -90,11 +90,7 @@ class TemplateLock(TemplateEntity, LockEntity): 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 cdd14921bc1..e757f561a7e 100644 --- a/homeassistant/components/template/sensor.py +++ b/homeassistant/components/template/sensor.py @@ -42,6 +42,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback 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 @@ class TriggerSensorEntity(TriggerEntity, RestoreSensor): """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/switch.py b/homeassistant/components/template/switch.py index 39270d3fc6d..5e75eafe233 100644 --- a/homeassistant/components/template/switch.py +++ b/homeassistant/components/template/switch.py @@ -113,6 +113,7 @@ class SwitchTemplate(TemplateEntity, SwitchEntity, RestoreEntity): 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 @@ class SwitchTemplate(TemplateEntity, SwitchEntity, RestoreEntity): 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/weather.py b/homeassistant/components/template/weather.py index d815655d775..4e9149ebd07 100644 --- a/homeassistant/components/template/weather.py +++ b/homeassistant/components/template/weather.py @@ -1,8 +1,9 @@ """Template platform that aggregates meteorological data.""" from __future__ import annotations +from dataclasses import asdict, dataclass from functools import partial -from typing import Any, Literal +from typing import Any, Literal, Self import voluptuous as vol @@ -22,18 +23,27 @@ from homeassistant.components.weather import ( ATTR_CONDITION_SUNNY, ATTR_CONDITION_WINDY, ATTR_CONDITION_WINDY_VARIANT, + DOMAIN as WEATHER_DOMAIN, ENTITY_ID_FORMAT, Forecast, WeatherEntity, WeatherEntityFeature, ) -from homeassistant.const import CONF_NAME, CONF_TEMPERATURE_UNIT, CONF_UNIQUE_ID +from homeassistant.const import ( + CONF_NAME, + CONF_TEMPERATURE_UNIT, + CONF_UNIQUE_ID, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError +from homeassistant.helpers import template import homeassistant.helpers.config_validation as cv from homeassistant.helpers.config_validation import PLATFORM_SCHEMA from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.restore_state import ExtraStoredData, RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.unit_conversion import ( DistanceConverter, @@ -42,7 +52,9 @@ from homeassistant.util.unit_conversion import ( TemperatureConverter, ) +from .coordinator import TriggerUpdateCoordinator from .template_entity import TemplateEntity, rewrite_common_legacy_to_modern_conf +from .trigger_entity import TriggerEntity CHECK_FORECAST_KEYS = ( set().union(Forecast.__annotations__.keys()) @@ -92,40 +104,38 @@ CONF_CLOUD_COVERAGE_TEMPLATE = "cloud_coverage_template" CONF_DEW_POINT_TEMPLATE = "dew_point_template" CONF_APPARENT_TEMPERATURE_TEMPLATE = "apparent_temperature_template" +WEATHER_SCHEMA = vol.Schema( + { + vol.Required(CONF_NAME): cv.template, + vol.Required(CONF_CONDITION_TEMPLATE): cv.template, + vol.Required(CONF_TEMPERATURE_TEMPLATE): cv.template, + vol.Required(CONF_HUMIDITY_TEMPLATE): cv.template, + vol.Optional(CONF_ATTRIBUTION_TEMPLATE): cv.template, + vol.Optional(CONF_PRESSURE_TEMPLATE): cv.template, + vol.Optional(CONF_WIND_SPEED_TEMPLATE): cv.template, + vol.Optional(CONF_WIND_BEARING_TEMPLATE): cv.template, + vol.Optional(CONF_OZONE_TEMPLATE): cv.template, + vol.Optional(CONF_VISIBILITY_TEMPLATE): cv.template, + vol.Optional(CONF_FORECAST_TEMPLATE): cv.template, + vol.Optional(CONF_FORECAST_DAILY_TEMPLATE): cv.template, + vol.Optional(CONF_FORECAST_HOURLY_TEMPLATE): cv.template, + vol.Optional(CONF_FORECAST_TWICE_DAILY_TEMPLATE): cv.template, + vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Optional(CONF_TEMPERATURE_UNIT): vol.In(TemperatureConverter.VALID_UNITS), + vol.Optional(CONF_PRESSURE_UNIT): vol.In(PressureConverter.VALID_UNITS), + vol.Optional(CONF_WIND_SPEED_UNIT): vol.In(SpeedConverter.VALID_UNITS), + vol.Optional(CONF_VISIBILITY_UNIT): vol.In(DistanceConverter.VALID_UNITS), + vol.Optional(CONF_PRECIPITATION_UNIT): vol.In(DistanceConverter.VALID_UNITS), + vol.Optional(CONF_WIND_GUST_SPEED_TEMPLATE): cv.template, + vol.Optional(CONF_CLOUD_COVERAGE_TEMPLATE): cv.template, + vol.Optional(CONF_DEW_POINT_TEMPLATE): cv.template, + vol.Optional(CONF_APPARENT_TEMPERATURE_TEMPLATE): cv.template, + } +) + PLATFORM_SCHEMA = vol.All( cv.deprecated(CONF_FORECAST_TEMPLATE), - PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_NAME): cv.string, - vol.Required(CONF_CONDITION_TEMPLATE): cv.template, - vol.Required(CONF_TEMPERATURE_TEMPLATE): cv.template, - vol.Required(CONF_HUMIDITY_TEMPLATE): cv.template, - vol.Optional(CONF_ATTRIBUTION_TEMPLATE): cv.template, - vol.Optional(CONF_PRESSURE_TEMPLATE): cv.template, - vol.Optional(CONF_WIND_SPEED_TEMPLATE): cv.template, - vol.Optional(CONF_WIND_BEARING_TEMPLATE): cv.template, - vol.Optional(CONF_OZONE_TEMPLATE): cv.template, - vol.Optional(CONF_VISIBILITY_TEMPLATE): cv.template, - vol.Optional(CONF_FORECAST_TEMPLATE): cv.template, - vol.Optional(CONF_FORECAST_DAILY_TEMPLATE): cv.template, - vol.Optional(CONF_FORECAST_HOURLY_TEMPLATE): cv.template, - vol.Optional(CONF_FORECAST_TWICE_DAILY_TEMPLATE): cv.template, - vol.Optional(CONF_UNIQUE_ID): cv.string, - vol.Optional(CONF_TEMPERATURE_UNIT): vol.In( - TemperatureConverter.VALID_UNITS - ), - vol.Optional(CONF_PRESSURE_UNIT): vol.In(PressureConverter.VALID_UNITS), - vol.Optional(CONF_WIND_SPEED_UNIT): vol.In(SpeedConverter.VALID_UNITS), - vol.Optional(CONF_VISIBILITY_UNIT): vol.In(DistanceConverter.VALID_UNITS), - vol.Optional(CONF_PRECIPITATION_UNIT): vol.In( - DistanceConverter.VALID_UNITS - ), - vol.Optional(CONF_WIND_GUST_SPEED_TEMPLATE): cv.template, - vol.Optional(CONF_CLOUD_COVERAGE_TEMPLATE): cv.template, - vol.Optional(CONF_DEW_POINT_TEMPLATE): cv.template, - vol.Optional(CONF_APPARENT_TEMPERATURE_TEMPLATE): cv.template, - } - ), + PLATFORM_SCHEMA.extend(WEATHER_SCHEMA.schema), ) @@ -136,6 +146,12 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Template weather.""" + if discovery_info and "coordinator" in discovery_info: + async_add_entities( + TriggerWeatherEntity(hass, discovery_info["coordinator"], config) + for config in discovery_info["entities"] + ) + return config = rewrite_common_legacy_to_modern_conf(config) unique_id = config.get(CONF_UNIQUE_ID) @@ -452,3 +468,248 @@ class WeatherTemplate(TemplateEntity, WeatherEntity): ) continue return result + + +@dataclass(kw_only=True) +class WeatherExtraStoredData(ExtraStoredData): + """Object to hold extra stored data.""" + + last_apparent_temperature: float | None + last_cloud_coverage: int | None + last_dew_point: float | None + last_humidity: float | None + last_ozone: float | None + last_pressure: float | None + last_temperature: float | None + last_visibility: float | None + last_wind_bearing: float | str | None + last_wind_gust_speed: float | None + last_wind_speed: float | None + + def as_dict(self) -> dict[str, Any]: + """Return a dict representation of the event data.""" + return asdict(self) + + @classmethod + def from_dict(cls, restored: dict[str, Any]) -> Self | None: + """Initialize a stored event state from a dict.""" + try: + return cls( + last_apparent_temperature=restored["last_apparent_temperature"], + last_cloud_coverage=restored["last_cloud_coverage"], + last_dew_point=restored["last_dew_point"], + last_humidity=restored["last_humidity"], + last_ozone=restored["last_ozone"], + last_pressure=restored["last_pressure"], + last_temperature=restored["last_temperature"], + last_visibility=restored["last_visibility"], + last_wind_bearing=restored["last_wind_bearing"], + last_wind_gust_speed=restored["last_wind_gust_speed"], + last_wind_speed=restored["last_wind_speed"], + ) + except KeyError: + return None + + +class TriggerWeatherEntity(TriggerEntity, WeatherEntity, RestoreEntity): + """Sensor entity based on trigger data.""" + + domain = WEATHER_DOMAIN + extra_template_keys = ( + CONF_CONDITION_TEMPLATE, + CONF_TEMPERATURE_TEMPLATE, + CONF_HUMIDITY_TEMPLATE, + ) + + def __init__( + self, + hass: HomeAssistant, + coordinator: TriggerUpdateCoordinator, + config: ConfigType, + ) -> None: + """Initialize.""" + super().__init__(hass, coordinator, config) + self._attr_native_precipitation_unit = config.get(CONF_PRECIPITATION_UNIT) + self._attr_native_pressure_unit = config.get(CONF_PRESSURE_UNIT) + self._attr_native_temperature_unit = config.get(CONF_TEMPERATURE_UNIT) + self._attr_native_visibility_unit = config.get(CONF_VISIBILITY_UNIT) + self._attr_native_wind_speed_unit = config.get(CONF_WIND_SPEED_UNIT) + + self._attr_supported_features = 0 + if config.get(CONF_FORECAST_DAILY_TEMPLATE): + self._attr_supported_features |= WeatherEntityFeature.FORECAST_DAILY + if config.get(CONF_FORECAST_HOURLY_TEMPLATE): + self._attr_supported_features |= WeatherEntityFeature.FORECAST_HOURLY + if config.get(CONF_FORECAST_TWICE_DAILY_TEMPLATE): + self._attr_supported_features |= WeatherEntityFeature.FORECAST_TWICE_DAILY + + for key in ( + CONF_APPARENT_TEMPERATURE_TEMPLATE, + CONF_CLOUD_COVERAGE_TEMPLATE, + CONF_DEW_POINT_TEMPLATE, + CONF_FORECAST_DAILY_TEMPLATE, + CONF_FORECAST_HOURLY_TEMPLATE, + CONF_FORECAST_TWICE_DAILY_TEMPLATE, + CONF_OZONE_TEMPLATE, + CONF_PRESSURE_TEMPLATE, + CONF_VISIBILITY_TEMPLATE, + CONF_WIND_BEARING_TEMPLATE, + CONF_WIND_GUST_SPEED_TEMPLATE, + CONF_WIND_SPEED_TEMPLATE, + ): + if isinstance(config.get(key), template.Template): + self._to_render_simple.append(key) + self._parse_result.add(key) + + async def async_added_to_hass(self) -> None: + """Restore last state.""" + await super().async_added_to_hass() + if ( + (state := await self.async_get_last_state()) + and state.state is not None + and state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE) + and (weather_data := await self.async_get_last_weather_data()) + ): + self._rendered[ + CONF_APPARENT_TEMPERATURE_TEMPLATE + ] = weather_data.last_apparent_temperature + self._rendered[ + CONF_CLOUD_COVERAGE_TEMPLATE + ] = weather_data.last_cloud_coverage + self._rendered[CONF_CONDITION_TEMPLATE] = state.state + self._rendered[CONF_DEW_POINT_TEMPLATE] = weather_data.last_dew_point + self._rendered[CONF_HUMIDITY_TEMPLATE] = weather_data.last_humidity + self._rendered[CONF_OZONE_TEMPLATE] = weather_data.last_ozone + self._rendered[CONF_PRESSURE_TEMPLATE] = weather_data.last_pressure + self._rendered[CONF_TEMPERATURE_TEMPLATE] = weather_data.last_temperature + self._rendered[CONF_VISIBILITY_TEMPLATE] = weather_data.last_visibility + self._rendered[CONF_WIND_BEARING_TEMPLATE] = weather_data.last_wind_bearing + self._rendered[ + CONF_WIND_GUST_SPEED_TEMPLATE + ] = weather_data.last_wind_gust_speed + self._rendered[CONF_WIND_SPEED_TEMPLATE] = weather_data.last_wind_speed + + @property + def condition(self) -> str | None: + """Return the current condition.""" + return self._rendered.get(CONF_CONDITION_TEMPLATE) + + @property + def native_temperature(self) -> float | None: + """Return the temperature.""" + return vol.Any(vol.Coerce(float), None)( + self._rendered.get(CONF_TEMPERATURE_TEMPLATE) + ) + + @property + def humidity(self) -> float | None: + """Return the humidity.""" + return vol.Any(vol.Coerce(float), None)( + self._rendered.get(CONF_HUMIDITY_TEMPLATE) + ) + + @property + def native_wind_speed(self) -> float | None: + """Return the wind speed.""" + return vol.Any(vol.Coerce(float), None)( + self._rendered.get(CONF_WIND_SPEED_TEMPLATE) + ) + + @property + def wind_bearing(self) -> float | str | None: + """Return the wind bearing.""" + return vol.Any(vol.Coerce(float), vol.Coerce(str), None)( + self._rendered.get(CONF_WIND_BEARING_TEMPLATE) + ) + + @property + def ozone(self) -> float | None: + """Return the ozone level.""" + return vol.Any(vol.Coerce(float), None)( + self._rendered.get(CONF_OZONE_TEMPLATE), + ) + + @property + def native_visibility(self) -> float | None: + """Return the visibility.""" + return vol.Any(vol.Coerce(float), None)( + self._rendered.get(CONF_VISIBILITY_TEMPLATE) + ) + + @property + def native_pressure(self) -> float | None: + """Return the air pressure.""" + return vol.Any(vol.Coerce(float), None)( + self._rendered.get(CONF_PRESSURE_TEMPLATE) + ) + + @property + def native_wind_gust_speed(self) -> float | None: + """Return the wind gust speed.""" + return vol.Any(vol.Coerce(float), None)( + self._rendered.get(CONF_WIND_GUST_SPEED_TEMPLATE) + ) + + @property + def cloud_coverage(self) -> float | None: + """Return the cloud coverage.""" + return vol.Any(vol.Coerce(float), None)( + self._rendered.get(CONF_CLOUD_COVERAGE_TEMPLATE) + ) + + @property + def native_dew_point(self) -> float | None: + """Return the dew point.""" + return vol.Any(vol.Coerce(float), None)( + self._rendered.get(CONF_DEW_POINT_TEMPLATE) + ) + + @property + def native_apparent_temperature(self) -> float | None: + """Return the apparent temperature.""" + return vol.Any(vol.Coerce(float), None)( + self._rendered.get(CONF_APPARENT_TEMPERATURE_TEMPLATE) + ) + + async def async_forecast_daily(self) -> list[Forecast]: + """Return the daily forecast in native units.""" + return vol.Any(vol.Coerce(list), None)( + self._rendered.get(CONF_FORECAST_DAILY_TEMPLATE) + ) + + async def async_forecast_hourly(self) -> list[Forecast]: + """Return the daily forecast in native units.""" + return vol.Any(vol.Coerce(list), None)( + self._rendered.get(CONF_FORECAST_HOURLY_TEMPLATE) + ) + + async def async_forecast_twice_daily(self) -> list[Forecast]: + """Return the daily forecast in native units.""" + return vol.Any(vol.Coerce(list), None)( + self._rendered.get(CONF_FORECAST_TWICE_DAILY_TEMPLATE) + ) + + @property + def extra_restore_state_data(self) -> WeatherExtraStoredData: + """Return weather specific state data to be restored.""" + return WeatherExtraStoredData( + last_apparent_temperature=self._rendered.get( + CONF_APPARENT_TEMPERATURE_TEMPLATE + ), + last_cloud_coverage=self._rendered.get(CONF_CLOUD_COVERAGE_TEMPLATE), + last_dew_point=self._rendered.get(CONF_DEW_POINT_TEMPLATE), + last_humidity=self._rendered.get(CONF_HUMIDITY_TEMPLATE), + last_ozone=self._rendered.get(CONF_OZONE_TEMPLATE), + last_pressure=self._rendered.get(CONF_PRESSURE_TEMPLATE), + last_temperature=self._rendered.get(CONF_TEMPERATURE_TEMPLATE), + last_visibility=self._rendered.get(CONF_VISIBILITY_TEMPLATE), + last_wind_bearing=self._rendered.get(CONF_WIND_BEARING_TEMPLATE), + last_wind_gust_speed=self._rendered.get(CONF_WIND_GUST_SPEED_TEMPLATE), + last_wind_speed=self._rendered.get(CONF_WIND_SPEED_TEMPLATE), + ) + + async def async_get_last_weather_data(self) -> WeatherExtraStoredData | None: + """Restore weather specific state data.""" + if (restored_last_extra_data := await self.async_get_last_extra_data()) is None: + return None + return WeatherExtraStoredData.from_dict(restored_last_extra_data.as_dict()) diff --git a/homeassistant/components/tensorflow/manifest.json b/homeassistant/components/tensorflow/manifest.json index 71952431b5a..c8682941e28 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", - "Pillow==10.0.0" + "numpy==1.26.0", + "Pillow==10.0.1" ] } diff --git a/homeassistant/components/text/__init__.py b/homeassistant/components/text/__init__.py index 4182b177bf6..acc5f62a0cc 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 09642eb3079..00000000000 --- 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 e6b3d99ced4..82cab559d0e 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 3e702f0ebdb..6382c79b9ce 100644 --- a/homeassistant/components/threshold/binary_sensor.py +++ b/homeassistant/components/threshold/binary_sensor.py @@ -183,14 +183,14 @@ class ThresholdSensor(BinarySensorEntity): 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 @@ class ThresholdSensor(BinarySensorEntity): ) _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/strings.json b/homeassistant/components/tibber/strings.json index 2876bf5bd02..8306f25f587 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 228e2071b4a..17712b6aef1 100644 --- a/homeassistant/components/timer/__init__.py +++ b/homeassistant/components/timer/__init__.py @@ -205,7 +205,8 @@ class Timer(collection.CollectionEntity, RestoreEntity): """Initialize a timer.""" self._config: dict = config self._state: str = STATUS_IDLE - self._duration = cv.time_period_str(config[CONF_DURATION]) + self._configured_duration = cv.time_period_str(config[CONF_DURATION]) + self._running_duration: timedelta = self._configured_duration self._remaining: timedelta | None = None self._end: datetime | None = None self._listener: Callable[[], None] | None = None @@ -248,7 +249,7 @@ class Timer(collection.CollectionEntity, RestoreEntity): def extra_state_attributes(self): """Return the state attributes.""" attrs = { - ATTR_DURATION: _format_timedelta(self._duration), + ATTR_DURATION: _format_timedelta(self._running_duration), ATTR_EDITABLE: self.editable, } if self._end is not None: @@ -275,12 +276,12 @@ class Timer(collection.CollectionEntity, RestoreEntity): # Begin restoring state self._state = state.state - self._duration = cv.time_period(state.attributes[ATTR_DURATION]) # Nothing more to do if the timer is idle if self._state == STATUS_IDLE: return + self._running_duration = cv.time_period(state.attributes[ATTR_DURATION]) # If the timer was paused, we restore the remaining time if self._state == STATUS_PAUSED: self._remaining = cv.time_period(state.attributes[ATTR_REMAINING]) @@ -314,11 +315,11 @@ class Timer(collection.CollectionEntity, RestoreEntity): self._state = STATUS_ACTIVE start = dt_util.utcnow().replace(microsecond=0) - # Set remaining to new value if needed + # Set remaining and running duration unless resuming or restarting if duration: - self._remaining = self._duration = duration + self._remaining = self._running_duration = duration elif not self._remaining: - self._remaining = self._duration + self._remaining = self._running_duration self._end = start + self._remaining @@ -336,9 +337,9 @@ class Timer(collection.CollectionEntity, RestoreEntity): raise HomeAssistantError( f"Timer {self.entity_id} is not running, only active timers can be changed" ) - if self._remaining and (self._remaining + duration) > self._duration: + if self._remaining and (self._remaining + duration) > self._running_duration: raise HomeAssistantError( - f"Not possible to change timer {self.entity_id} beyond configured duration" + f"Not possible to change timer {self.entity_id} beyond duration" ) if self._remaining and (self._remaining + duration) < timedelta(): raise HomeAssistantError( @@ -377,6 +378,7 @@ class Timer(collection.CollectionEntity, RestoreEntity): self._state = STATUS_IDLE self._end = None self._remaining = None + self._running_duration = self._configured_duration self.hass.bus.async_fire( EVENT_TIMER_CANCELLED, {ATTR_ENTITY_ID: self.entity_id} ) @@ -395,6 +397,7 @@ class Timer(collection.CollectionEntity, RestoreEntity): self._state = STATUS_IDLE self._end = None self._remaining = None + self._running_duration = self._configured_duration self.hass.bus.async_fire( EVENT_TIMER_FINISHED, {ATTR_ENTITY_ID: self.entity_id, ATTR_FINISHED_AT: end.isoformat()}, @@ -412,6 +415,7 @@ class Timer(collection.CollectionEntity, RestoreEntity): end = self._end self._end = None self._remaining = None + self._running_duration = self._configured_duration self.hass.bus.async_fire( EVENT_TIMER_FINISHED, {ATTR_ENTITY_ID: self.entity_id, ATTR_FINISHED_AT: end.isoformat()}, @@ -421,6 +425,8 @@ class Timer(collection.CollectionEntity, RestoreEntity): async def async_update_config(self, config: ConfigType) -> None: """Handle when the config is updated.""" self._config = config - self._duration = cv.time_period_str(config[CONF_DURATION]) + self._configured_duration = cv.time_period_str(config[CONF_DURATION]) + if self._state == STATUS_IDLE: + self._running_duration = self._configured_duration self._restore = config.get(CONF_RESTORE, DEFAULT_RESTORE) self.async_write_ha_state() diff --git a/homeassistant/components/todoist/__init__.py b/homeassistant/components/todoist/__init__.py index 78a9cb89624..12b75a40bae 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 544144018dd..40ceb71ee5f 100644 --- a/homeassistant/components/todoist/calendar.py +++ b/homeassistant/components/todoist/calendar.py @@ -17,8 +17,10 @@ from homeassistant.components.calendar import ( 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 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( 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 async_setup_platform( 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 async_setup_platform( 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 async_setup_platform( 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 async_setup_platform( } ] } - 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 00000000000..6098df40ea0 --- /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 b573d1d1127..702c43883ea 100644 --- a/homeassistant/components/todoist/coordinator.py +++ b/homeassistant/components/todoist/coordinator.py @@ -3,7 +3,7 @@ from datetime import timedelta 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 @@ class TodoistCoordinator(DataUpdateCoordinator[list[Task]]): 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 @@ class TodoistCoordinator(DataUpdateCoordinator[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 a83cdbe1b09..72d76108353 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 1ed092e5cf6..123b5d07ed7 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/sensor.py b/homeassistant/components/tomorrowio/sensor.py index 119a3dfe582..4aa2748ad30 100644 --- a/homeassistant/components/tomorrowio/sensor.py +++ b/homeassistant/components/tomorrowio/sensor.py @@ -35,7 +35,6 @@ from homeassistant.const import ( ) 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,42 +110,53 @@ 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, + state_class=SensorStateClass.MEASUREMENT, ), 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, device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, ), # 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, + state_class=SensorStateClass.MEASUREMENT, ), # 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, imperial_conversion=(1 / 3.15459), device_class=SensorDeviceClass.IRRADIANCE, + state_class=SensorStateClass.MEASUREMENT, ), # 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, unit_metric=UnitOfLength.KILOMETERS, + device_class=SensorDeviceClass.DISTANCE, + state_class=SensorStateClass.MEASUREMENT, imperial_conversion=lambda val: DistanceConverter.convert( val, UnitOfLength.KILOMETERS, @@ -154,11 +165,14 @@ SENSOR_TYPES = ( ), # 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, unit_metric=UnitOfLength.KILOMETERS, + device_class=SensorDeviceClass.DISTANCE, + state_class=SensorStateClass.MEASUREMENT, imperial_conversion=lambda val: DistanceConverter.convert( val, UnitOfLength.KILOMETERS, @@ -166,24 +180,29 @@ SENSOR_TYPES = ( ), ), 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, unit_metric=UnitOfSpeed.METERS_PER_SECOND, + device_class=SensorDeviceClass.SPEED, + state_class=SensorStateClass.MEASUREMENT, imperial_conversion=lambda val: SpeedConverter.convert( val, UnitOfSpeed.METERS_PER_SECOND, UnitOfSpeed.MILES_PER_HOUR ), ), 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,120 +211,145 @@ SENSOR_TYPES = ( # 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, + state_class=SensorStateClass.MEASUREMENT, ), 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, + state_class=SensorStateClass.MEASUREMENT, ), 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, + state_class=SensorStateClass.MEASUREMENT, ), # 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), device_class=SensorDeviceClass.NITROGEN_DIOXIDE, + state_class=SensorStateClass.MEASUREMENT, ), # 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, device_class=SensorDeviceClass.CO, + state_class=SensorStateClass.MEASUREMENT, ), # 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, + state_class=SensorStateClass.MEASUREMENT, ), 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, + state_class=SensorStateClass.MEASUREMENT, ), 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 +400,7 @@ class BaseTomorrowioSensorEntity(TomorrowioEntity, SensorEntity): 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 +445,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 890793b898d..afb341b47ed 100644 --- a/homeassistant/components/tplink/entity.py +++ b/homeassistant/components/tplink/entity.py @@ -41,18 +41,14 @@ class CoordinatedTPLinkEntity(CoordinatorEntity[TPLinkDataUpdateCoordinator]): 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 bb330ef417a..5008b7e4b18 100644 --- a/homeassistant/components/tplink_omada/entity.py +++ b/homeassistant/components/tplink_omada/entity.py @@ -20,14 +20,10 @@ class OmadaDeviceEntity(CoordinatorEntity[OmadaCoordinator[T]], Generic[T]): """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 9c303b24661..3215a9ba77d 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 0e373e1a44f..00296f3108c 100644 --- a/homeassistant/components/tractive/device_tracker.py +++ b/homeassistant/components/tractive/device_tracker.py @@ -99,6 +99,7 @@ class TractiveDeviceTracker(TractiveEntity, TrackerEntity): 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/tradfri/base_class.py b/homeassistant/components/tradfri/base_class.py index d186e19a2c8..416eb175d31 100644 --- a/homeassistant/components/tradfri/base_class.py +++ b/homeassistant/components/tradfri/base_class.py @@ -55,7 +55,16 @@ class TradfriBaseEntity(CoordinatorEntity[TradfriDeviceDataUpdateCoordinator]): 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 @@ class TradfriBaseEntity(CoordinatorEntity[TradfriDeviceDataUpdateCoordinator]): 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/fan.py b/homeassistant/components/tradfri/fan.py index a26dfa1d9a0..c41b24a2647 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 @@ class TradfriAirPurifierFan(TradfriBaseEntity, FanEntity): """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 @@ class TradfriAirPurifierFan(TradfriBaseEntity, FanEntity): 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 dfac8416c49..5575f32788a 100644 --- a/homeassistant/components/trafikverket_camera/__init__.py +++ b/homeassistant/components/trafikverket_camera/__init__.py @@ -4,10 +4,6 @@ from __future__ import annotations 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 @@ from .coordinator import TVDataUpdateCoordinator 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 00000000000..c9da5bd5d8a --- /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 936e460638f..808d687a131 100644 --- a/homeassistant/components/trafikverket_camera/camera.py +++ b/homeassistant/components/trafikverket_camera/camera.py @@ -8,12 +8,11 @@ from homeassistant.components.camera import Camera 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 b8a14a5424e..e1f8220c4ff 100644 --- a/homeassistant/components/trafikverket_camera/config_flow.py +++ b/homeassistant/components/trafikverket_camera/config_flow.py @@ -10,7 +10,7 @@ from pytrafikverket.exceptions import ( 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 @@ class TVCameraConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 @@ class TVCameraConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 @@ class TVCameraConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 6657ab1a853..ff40d1bbc91 100644 --- a/homeassistant/components/trafikverket_camera/const.py +++ b/homeassistant/components/trafikverket_camera/const.py @@ -3,7 +3,7 @@ from homeassistant.const import Platform 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 00000000000..ec1d4d8f76b --- /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 440d7237171..d23631c6878 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 b6b608749ad..00000000000 --- 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 00000000000..96231bba755 --- /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 c128f7729bc..651225934cd 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 47f1e62be00..9d0b904290c 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 47b4c21c867..ab1f7feb3f7 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 8c46afa5972..138af544066 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 020f7903060..2d00f35202c 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 @@ from homeassistant.components.binary_sensor import ( DEVICE_CLASSES_SCHEMA, ENTITY_ID_FORMAT, PLATFORM_SCHEMA, + BinarySensorDeviceClass, BinarySensorEntity, ) from homeassistant.const import ( @@ -22,6 +25,7 @@ from homeassistant.const import ( CONF_ENTITY_ID, CONF_FRIENDLY_NAME, CONF_SENSORS, + STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN, ) @@ -34,6 +38,7 @@ from homeassistant.helpers.event import ( async_track_state_change_event, ) from homeassistant.helpers.reload import async_setup_reload_service +from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, EventType from homeassistant.util.dt import utcnow @@ -113,59 +118,49 @@ async def async_setup_platform( async_add_entities(sensors) -class SensorTrend(BinarySensorEntity): +class SensorTrend(BinarySensorEntity, RestoreEntity): """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, @@ -201,6 +196,12 @@ class SensorTrend(BinarySensorEntity): ) ) + if not (state := await self.async_get_last_state()): + return + if state.state == STATE_UNKNOWN: + return + self._state = state.state == STATE_ON + async def async_update(self) -> None: """Get the latest data and update the states.""" # Remove outdated samples @@ -224,7 +225,7 @@ class SensorTrend(BinarySensorEntity): 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 77a0044ca1f..0adbf623346 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 249e427c591..f1120ed2750 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/__init__.py b/homeassistant/components/twinkly/__init__.py index 3b0228e64b0..897bfaf4e20 100644 --- a/homeassistant/components/twinkly/__init__.py +++ b/homeassistant/components/twinkly/__init__.py @@ -6,12 +6,12 @@ from aiohttp import ClientError from ttls.client import Twinkly from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.const import ATTR_SW_VERSION, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import CONF_HOST, DATA_CLIENT, DATA_DEVICE_INFO, DOMAIN +from .const import ATTR_VERSION, CONF_HOST, DATA_CLIENT, DATA_DEVICE_INFO, DOMAIN PLATFORMS = [Platform.LIGHT] @@ -30,12 +30,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: device_info = await client.get_details() + software_version = await client.get_firmware_version() except (asyncio.TimeoutError, ClientError) as exception: raise ConfigEntryNotReady from exception - hass.data[DOMAIN][entry.entry_id][DATA_CLIENT] = client - hass.data[DOMAIN][entry.entry_id][DATA_DEVICE_INFO] = device_info - + hass.data[DOMAIN][entry.entry_id] = { + DATA_CLIENT: client, + DATA_DEVICE_INFO: device_info, + ATTR_SW_VERSION: software_version.get(ATTR_VERSION), + } await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/twinkly/diagnostics.py b/homeassistant/components/twinkly/diagnostics.py new file mode 100644 index 00000000000..598eab0fca5 --- /dev/null +++ b/homeassistant/components/twinkly/diagnostics.py @@ -0,0 +1,41 @@ +"""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 ATTR_SW_VERSION, 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], + ATTR_SW_VERSION: hass.data[DOMAIN][entry.entry_id][ATTR_SW_VERSION], + "attributes": attributes, + }, + TO_REDACT, + ) diff --git a/homeassistant/components/twinkly/light.py b/homeassistant/components/twinkly/light.py index 5ddd22c8a23..6d0b31b06ed 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 @@ -20,13 +19,13 @@ from homeassistant.components.light import ( LightEntityFeature, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_MODEL +from homeassistant.const import ATTR_SW_VERSION, CONF_MODEL from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( - ATTR_VERSION, CONF_HOST, CONF_ID, CONF_NAME, @@ -53,8 +52,9 @@ async def async_setup_entry( client = hass.data[DOMAIN][config_entry.entry_id][DATA_CLIENT] device_info = hass.data[DOMAIN][config_entry.entry_id][DATA_DEVICE_INFO] + software_version = hass.data[DOMAIN][config_entry.entry_id][ATTR_SW_VERSION] - entity = TwinklyLight(config_entry, client, device_info) + entity = TwinklyLight(config_entry, client, device_info, software_version) async_add_entities([entity], update_before_add=True) @@ -62,14 +62,17 @@ 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, client: Twinkly, device_info, + software_version: str | None = None, ) -> 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 +96,30 @@ class TwinklyLight(LightEntity): 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 = "" + self._software_version = 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.""" @@ -168,16 +137,21 @@ class TwinklyLight(LightEntity): async def async_added_to_hass(self) -> None: """Device is added to hass.""" - software_version = await self._client.get_firmware_version() - if ATTR_VERSION in software_version: - self._software_version = software_version[ATTR_VERSION] - + if self._software_version: if AwesomeVersion(self._software_version) < AwesomeVersion( MIN_EFFECT_VERSION ): self._attr_supported_features = ( self.supported_features & ~LightEntityFeature.EFFECT ) + device_registry = dr.async_get(self.hass) + device_entry = device_registry.async_get_device( + {(DOMAIN, self._attr_unique_id)}, set() + ) + if device_entry: + device_registry.async_update_device( + device_entry.id, sw_version=self._software_version + ) async def async_turn_on(self, **kwargs: Any) -> None: """Turn device on.""" @@ -246,7 +220,7 @@ class TwinklyLight(LightEntity): 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 +232,7 @@ class TwinklyLight(LightEntity): _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 +240,7 @@ class TwinklyLight(LightEntity): ) 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 +263,7 @@ class TwinklyLight(LightEntity): 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 +273,20 @@ class TwinklyLight(LightEntity): 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 59deff915c3..c6ab0bab893 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/twitch/__init__.py b/homeassistant/components/twitch/__init__.py index 64feb17d6b5..76b6ec709ff 100644 --- a/homeassistant/components/twitch/__init__.py +++ b/homeassistant/components/twitch/__init__.py @@ -1 +1,53 @@ """The Twitch component.""" +from __future__ import annotations + +from aiohttp.client_exceptions import ClientError, ClientResponseError +from twitchAPI.twitch import Twitch + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.config_entry_oauth2_flow import ( + OAuth2Session, + async_get_config_entry_implementation, +) + +from .const import DOMAIN, OAUTH_SCOPES, PLATFORMS + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Twitch from a config entry.""" + implementation = await async_get_config_entry_implementation(hass, entry) + session = OAuth2Session(hass, entry, implementation) + try: + await session.async_ensure_token_valid() + except ClientResponseError as err: + if 400 <= err.status < 500: + raise ConfigEntryAuthFailed( + "OAuth session is not valid, reauth required" + ) from err + raise ConfigEntryNotReady from err + except ClientError as err: + raise ConfigEntryNotReady from err + + app_id = implementation.__dict__[CONF_CLIENT_ID] + access_token = entry.data[CONF_TOKEN][CONF_ACCESS_TOKEN] + client = await Twitch( + app_id=app_id, + authenticate_app=False, + ) + client.auto_refresh_auth = False + await client.set_user_authentication(access_token, scope=OAUTH_SCOPES) + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = client + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload Twitch config entry.""" + + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/twitch/application_credentials.py b/homeassistant/components/twitch/application_credentials.py new file mode 100644 index 00000000000..fd8b03db2ca --- /dev/null +++ b/homeassistant/components/twitch/application_credentials.py @@ -0,0 +1,14 @@ +"""application_credentials platform the Twitch integration.""" + +from homeassistant.components.application_credentials import AuthorizationServer +from homeassistant.core import HomeAssistant + +from .const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN + + +async def async_get_authorization_server(_: HomeAssistant) -> AuthorizationServer: + """Return authorization server.""" + return AuthorizationServer( + authorize_url=OAUTH2_AUTHORIZE, + token_url=OAUTH2_TOKEN, + ) diff --git a/homeassistant/components/twitch/config_flow.py b/homeassistant/components/twitch/config_flow.py new file mode 100644 index 00000000000..9e586b19a5a --- /dev/null +++ b/homeassistant/components/twitch/config_flow.py @@ -0,0 +1,189 @@ +"""Config flow for Twitch.""" +from __future__ import annotations + +from collections.abc import Mapping +import logging +from typing import Any + +from twitchAPI.helper import first +from twitchAPI.twitch import Twitch +from twitchAPI.type import AuthScope, InvalidTokenException + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_TOKEN +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN +from homeassistant.data_entry_flow import AbortFlow, FlowResult +from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue + +from .const import CONF_CHANNELS, CONF_REFRESH_TOKEN, DOMAIN, LOGGER, OAUTH_SCOPES + + +class OAuth2FlowHandler( + config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN +): + """Config flow to handle Twitch OAuth2 authentication.""" + + DOMAIN = DOMAIN + reauth_entry: ConfigEntry | None = None + + def __init__(self) -> None: + """Initialize flow.""" + super().__init__() + self.data: dict[str, Any] = {} + + @property + def logger(self) -> logging.Logger: + """Return logger.""" + return LOGGER + + @property + def extra_authorize_data(self) -> dict[str, Any]: + """Extra data that needs to be appended to the authorize url.""" + return {"scope": " ".join([scope.value for scope in OAUTH_SCOPES])} + + async def async_oauth_create_entry( + self, + data: dict[str, Any], + ) -> FlowResult: + """Handle the initial step.""" + + client = await Twitch( + app_id=self.flow_impl.__dict__[CONF_CLIENT_ID], + authenticate_app=False, + ) + client.auto_refresh_auth = False + await client.set_user_authentication( + data[CONF_TOKEN][CONF_ACCESS_TOKEN], scope=OAUTH_SCOPES + ) + user = await first(client.get_users()) + assert user + + user_id = user.id + + if not self.reauth_entry: + await self.async_set_unique_id(user_id) + self._abort_if_unique_id_configured() + + channels = [ + channel.broadcaster_login + async for channel in await client.get_followed_channels(user_id) + ] + + return self.async_create_entry( + title=user.display_name, data=data, options={CONF_CHANNELS: channels} + ) + + if self.reauth_entry.unique_id == user_id: + new_channels = self.reauth_entry.options[CONF_CHANNELS] + # Since we could not get all channels at import, we do it at the reauth + # immediately after. + if "imported" in self.reauth_entry.data: + channels = [ + channel.broadcaster_login + async for channel in await client.get_followed_channels(user_id) + ] + options = list(set(channels) - set(new_channels)) + new_channels = [*new_channels, *options] + + self.hass.config_entries.async_update_entry( + self.reauth_entry, + data=data, + options={CONF_CHANNELS: new_channels}, + ) + await self.hass.config_entries.async_reload(self.reauth_entry.entry_id) + return self.async_abort(reason="reauth_successful") + + return self.async_abort( + reason="wrong_account", + description_placeholders={"title": self.reauth_entry.title}, + ) + + 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"] + ) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Confirm reauth dialog.""" + if user_input is None: + return self.async_show_form(step_id="reauth_confirm") + return await self.async_step_user() + + async def async_step_import(self, config: dict[str, Any]) -> FlowResult: + """Import from yaml.""" + client = await Twitch( + app_id=config[CONF_CLIENT_ID], + authenticate_app=False, + ) + client.auto_refresh_auth = False + token = config[CONF_TOKEN] + try: + await client.set_user_authentication( + token, validate=True, scope=[AuthScope.USER_READ_SUBSCRIPTIONS] + ) + except InvalidTokenException: + async_create_issue( + self.hass, + DOMAIN, + "deprecated_yaml_invalid_token", + breaks_in_ha_version="2024.4.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml_invalid_token", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Twitch", + }, + ) + return self.async_abort(reason="invalid_token") + user = await first(client.get_users()) + assert user + await self.async_set_unique_id(user.id) + try: + self._abort_if_unique_id_configured() + except AbortFlow as err: + async_create_issue( + self.hass, + DOMAIN, + "deprecated_yaml_already_imported", + breaks_in_ha_version="2024.4.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml_already_imported", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Twitch", + }, + ) + raise err + async_create_issue( + self.hass, + HOMEASSISTANT_DOMAIN, + "deprecated_yaml", + breaks_in_ha_version="2024.4.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Twitch", + }, + ) + return self.async_create_entry( + title=user.display_name, + data={ + "auth_implementation": DOMAIN, + CONF_TOKEN: { + CONF_ACCESS_TOKEN: token, + CONF_REFRESH_TOKEN: "", + "expires_at": 0, + }, + "imported": True, + }, + options={CONF_CHANNELS: config[CONF_CHANNELS]}, + ) diff --git a/homeassistant/components/twitch/const.py b/homeassistant/components/twitch/const.py index 6626889a809..22286437eab 100644 --- a/homeassistant/components/twitch/const.py +++ b/homeassistant/components/twitch/const.py @@ -3,8 +3,18 @@ import logging from twitchAPI.twitch import AuthScope +from homeassistant.const import Platform + LOGGER = logging.getLogger(__package__) +PLATFORMS = [Platform.SENSOR] + +OAUTH2_AUTHORIZE = "https://id.twitch.tv/oauth2/authorize" +OAUTH2_TOKEN = "https://id.twitch.tv/oauth2/token" + +CONF_REFRESH_TOKEN = "refresh_token" + +DOMAIN = "twitch" CONF_CHANNELS = "channels" -OAUTH_SCOPES = [AuthScope.USER_READ_SUBSCRIPTIONS] +OAUTH_SCOPES = [AuthScope.USER_READ_SUBSCRIPTIONS, AuthScope.USER_READ_FOLLOWS] diff --git a/homeassistant/components/twitch/manifest.json b/homeassistant/components/twitch/manifest.json index 5613360c594..810982d0cb4 100644 --- a/homeassistant/components/twitch/manifest.json +++ b/homeassistant/components/twitch/manifest.json @@ -2,8 +2,10 @@ "domain": "twitch", "name": "Twitch", "codeowners": ["@joostlek"], + "config_flow": true, + "dependencies": ["application_credentials"], "documentation": "https://www.home-assistant.io/integrations/twitch", "iot_class": "cloud_polling", "loggers": ["twitch"], - "requirements": ["twitchAPI==3.10.0"] + "requirements": ["twitchAPI==4.0.0"] } diff --git a/homeassistant/components/twitch/sensor.py b/homeassistant/components/twitch/sensor.py index 3211ca1952b..05fd3fa3e71 100644 --- a/homeassistant/components/twitch/sensor.py +++ b/homeassistant/components/twitch/sensor.py @@ -4,24 +4,27 @@ from __future__ import annotations from twitchAPI.helper import first from twitchAPI.twitch import ( AuthType, - InvalidTokenException, - MissingScopeException, Twitch, TwitchAPIException, - TwitchAuthorizationException, TwitchResourceNotFound, TwitchUser, ) import voluptuous as vol +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_TOKEN from homeassistant.core import 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 .const import CONF_CHANNELS, LOGGER, OAUTH_SCOPES +from .const import CONF_CHANNELS, DOMAIN, LOGGER, OAUTH_SCOPES PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -49,6 +52,11 @@ STATE_OFFLINE = "offline" STATE_STREAMING = "streaming" +def chunk_list(lst: list, chunk_size: int) -> list[list]: + """Split a list into chunks of chunk_size.""" + return [lst[i : i + chunk_size] for i in range(0, len(lst), chunk_size)] + + async def async_setup_platform( hass: HomeAssistant, config: ConfigType, @@ -56,42 +64,55 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Twitch platform.""" - channels = config[CONF_CHANNELS] - client_id = config[CONF_CLIENT_ID] - client_secret = config[CONF_CLIENT_SECRET] - oauth_token = config.get(CONF_TOKEN) - - try: - client = await Twitch( - app_id=client_id, - app_secret=client_secret, - target_app_auth_scope=OAUTH_SCOPES, - ) - client.auto_refresh_auth = False - except TwitchAuthorizationException: - LOGGER.error("Invalid client ID or client secret") - return - - if oauth_token: - try: - await client.set_user_authentication( - token=oauth_token, scope=OAUTH_SCOPES, validate=True - ) - except MissingScopeException: - LOGGER.error("OAuth token is missing required scope") - return - except InvalidTokenException: - LOGGER.error("OAuth token is invalid") - return - - twitch_users: list[TwitchUser] = [] - async for channel in client.get_users(logins=channels): - twitch_users.append(channel) - - async_add_entities( - [TwitchSensor(channel, client) for channel in twitch_users], - True, + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(config[CONF_CLIENT_ID], config[CONF_CLIENT_SECRET]), ) + if CONF_TOKEN in config: + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config + ) + ) + else: + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml_credentials_imported", + breaks_in_ha_version="2024.4.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml_credentials_imported", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Twitch", + }, + ) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Initialize entries.""" + client = hass.data[DOMAIN][entry.entry_id] + + channels = entry.options[CONF_CHANNELS] + + entities: list[TwitchSensor] = [] + + # Split channels into chunks of 100 to avoid hitting the rate limit + for chunk in chunk_list(channels, 100): + entities.extend( + [ + TwitchSensor(channel, client) + async for channel in client.get_users(logins=chunk) + ] + ) + + async_add_entities(entities, True) class TwitchSensor(SensorEntity): @@ -109,7 +130,7 @@ class TwitchSensor(SensorEntity): async def async_update(self) -> None: """Update device state.""" - followers = (await self._client.get_users_follows(to_id=self._channel.id)).total + followers = (await self._client.get_channel_followers(self._channel.id)).total self._attr_extra_state_attributes = { ATTR_FOLLOWING: followers, ATTR_VIEWS: self._channel.view_count, @@ -149,13 +170,11 @@ class TwitchSensor(SensorEntity): except TwitchAPIException as exc: LOGGER.error("Error response on check_user_subscription: %s", exc) - follows = ( - await self._client.get_users_follows( - from_id=user.id, to_id=self._channel.id - ) - ).data - self._attr_extra_state_attributes[ATTR_FOLLOW] = len(follows) > 0 - if len(follows): - self._attr_extra_state_attributes[ATTR_FOLLOW_SINCE] = follows[ + follows = await self._client.get_followed_channels( + user.id, broadcaster_id=self._channel.id + ) + self._attr_extra_state_attributes[ATTR_FOLLOW] = follows.total > 0 + if follows.total: + self._attr_extra_state_attributes[ATTR_FOLLOW_SINCE] = follows.data[ 0 ].followed_at diff --git a/homeassistant/components/twitch/strings.json b/homeassistant/components/twitch/strings.json new file mode 100644 index 00000000000..45f88747128 --- /dev/null +++ b/homeassistant/components/twitch/strings.json @@ -0,0 +1,30 @@ +{ + "config": { + "step": { + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The Twitch integration needs to re-authenticate your account" + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "unknown": "[%key:common::config_flow::error::unknown%]", + "wrong_account": "Wrong account: Please authenticate with {username}." + } + }, + "issues": { + "deprecated_yaml_invalid_token": { + "title": "The {integration_title} YAML configuration is being removed", + "description": "Configuring {integration_title} using YAML is being removed.\n\nYour configuration couldn't be imported because the token in the configuration.yaml was invalid.\n\nPlease add Twitch again via the UI.\n\nRemove the `{domain}` configuration from your configuration.yaml file and restart Home Assistant to fix this issue." + }, + "deprecated_yaml_credentials_imported": { + "title": "The {integration_title} YAML configuration is being removed", + "description": "Configuring {integration_title} using YAML is being removed.\n\nYour application credentials are imported, but a config entry could not be created because there was no access token.\n\nPlease add Twitch again via the UI.\n\nRemove the `{domain}` configuration from your configuration.yaml file and restart Home Assistant to fix this issue." + }, + "deprecated_yaml_already_imported": { + "title": "The {integration_title} YAML configuration is being removed", + "description": "Configuring {integration_title} using YAML is being removed.\n\nYour application credentials are already imported.\n\nRemove the `{domain}` configuration from your configuration.yaml file and restart Home Assistant to fix this issue." + } + } +} diff --git a/homeassistant/components/ultraloq/__init__.py b/homeassistant/components/ultraloq/__init__.py new file mode 100644 index 00000000000..b650c59a5de --- /dev/null +++ b/homeassistant/components/ultraloq/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Ultraloq.""" diff --git a/homeassistant/components/ultraloq/manifest.json b/homeassistant/components/ultraloq/manifest.json new file mode 100644 index 00000000000..4775ba6caa3 --- /dev/null +++ b/homeassistant/components/ultraloq/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "ultraloq", + "name": "Ultraloq", + "integration_type": "virtual", + "iot_standards": ["zwave"] +} diff --git a/homeassistant/components/unifi/__init__.py b/homeassistant/components/unifi/__init__.py index 10959b8965c..4337899a50f 100644 --- a/homeassistant/components/unifi/__init__.py +++ b/homeassistant/components/unifi/__init__.py @@ -6,7 +6,7 @@ from homeassistant.config_entries import ConfigEntry 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) @@ -55,7 +52,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b if len(hass.data[UNIFI_DOMAIN]) == 1: async_setup_services(hass) - api.start_websocket() + controller.start_websocket() config_entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, controller.shutdown) @@ -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 0235f6156cc..7471675123a 100644 --- a/homeassistant/components/unifi/button.py +++ b/homeassistant/components/unifi/button.py @@ -24,7 +24,6 @@ from homeassistant.const import EntityCategory 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 ba188f80135..620b928176e 100644 --- a/homeassistant/components/unifi/controller.py +++ b/homeassistant/components/unifi/controller.py @@ -12,7 +12,6 @@ import aiounifi from aiounifi.interfaces.api_handlers import ItemEvent from aiounifi.models.configuration import Configuration from aiounifi.models.device import DeviceSetPoePortModeRequest -from aiounifi.websocket import WebsocketState from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -21,14 +20,9 @@ from homeassistant.const import ( 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 +33,11 @@ from homeassistant.helpers.dispatcher import ( 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, @@ -88,7 +80,7 @@ class UniFiController: self.config_entry = config_entry self.api = api - api.ws_state_callback = self.async_unifi_ws_state_callback + self.ws_task: asyncio.Task | None = None self.available = True self.wireless_clients = hass.data[UNIFI_WIRELESS_CLIENTS] @@ -162,6 +154,24 @@ class UniFiController: 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, @@ -212,23 +222,6 @@ class UniFiController: for description in descriptions: async_load_entities(description) - @callback - def async_unifi_ws_state_callback(self, state: WebsocketState) -> None: - """Handle messages back from UniFi library.""" - if state == WebsocketState.DISCONNECTED and self.available: - LOGGER.warning("Lost connection to UniFi Network") - - if (state == WebsocketState.RUNNING and not self.available) or ( - state == WebsocketState.DISCONNECTED and self.available - ): - self.available = state == WebsocketState.RUNNING - async_dispatcher_send(self.hass, self.signal_reachable) - - if not self.available: - self.hass.loop.call_later(RETRY_TIMER, self.reconnect, True) - else: - LOGGER.info("Connected to UniFi Network") - @property def signal_reachable(self) -> str: """Integration specific event to signal a change in connection status.""" @@ -251,30 +244,9 @@ class UniFiController: 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())) @@ -377,6 +349,19 @@ class UniFiController: controller.load_config_entry_options() async_dispatcher_send(hass, controller.signal_options_update) + @callback + def start_websocket(self) -> None: + """Start up connection to websocket.""" + + async def _websocket_runner() -> None: + """Start websocket.""" + await self.api.start_websocket() + self.available = False + async_dispatcher_send(self.hass, self.signal_reachable) + self.hass.loop.call_later(RETRY_TIMER, self.reconnect, True) + + self.ws_task = self.hass.loop.create_task(_websocket_runner()) + @callback def reconnect(self, log: bool = False) -> None: """Prepare to reconnect UniFi session.""" @@ -389,7 +374,11 @@ class UniFiController: try: async with asyncio.timeout(5): await self.api.login() - self.api.start_websocket() + self.start_websocket() + + if not self.available: + self.available = True + async_dispatcher_send(self.hass, self.signal_reachable) except ( asyncio.TimeoutError, @@ -405,7 +394,8 @@ class UniFiController: Used as an argument to EventBus.async_listen_once. """ - self.api.stop_websocket() + if self.ws_task is not None: + self.ws_task.cancel() async def async_reset(self) -> bool: """Reset this controller to default state. @@ -413,7 +403,18 @@ class UniFiController: Will cancel any scheduled setup retry and will unload the config entry. """ - self.api.stop_websocket() + if self.ws_task is not None: + self.ws_task.cancel() + + _, pending = await asyncio.wait([self.ws_task], timeout=10) + + if pending: + LOGGER.warning( + "Unloading %s (%s) config entry. Task %s did not complete in time", + self.config_entry.title, + self.config_entry.domain, + self.ws_task, + ) unload_ok = await self.hass.config_entries.async_unload_platforms( self.config_entry, PLATFORMS diff --git a/homeassistant/components/unifi/device_tracker.py b/homeassistant/components/unifi/device_tracker.py index 71b0a9869a9..22a530e0369 100644 --- a/homeassistant/components/unifi/device_tracker.py +++ b/homeassistant/components/unifi/device_tracker.py @@ -24,7 +24,6 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect 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, @@ -180,7 +179,6 @@ ENTITY_DESCRIPTIONS: tuple[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 ) diff --git a/homeassistant/components/unifi/image.py b/homeassistant/components/unifi/image.py index 8231b87ee85..2318702f0d1 100644 --- a/homeassistant/components/unifi/image.py +++ b/homeassistant/components/unifi/image.py @@ -20,7 +20,6 @@ from homeassistant.core import HomeAssistant, callback 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 8734fd7dce5..7673402aaac 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==62"], + "requirements": ["aiounifi==63"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py index 142bd587853..86c6b0d6352 100644 --- a/homeassistant/components/unifi/sensor.py +++ b/homeassistant/components/unifi/sensor.py @@ -27,6 +27,7 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, + UnitOfTemperature, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, UnitOfInformation, UnitOfPower @@ -34,7 +35,6 @@ from homeassistant.core import HomeAssistant, callback 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 @@ ENTITY_DESCRIPTIONS: tuple[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 @@ ENTITY_DESCRIPTIONS: tuple[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 560e150e63c..0aa39914686 100644 --- a/homeassistant/components/unifi/switch.py +++ b/homeassistant/components/unifi/switch.py @@ -43,7 +43,7 @@ from homeassistant.core import HomeAssistant, callback 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 6526a02da83..65b26736cf1 100644 --- a/homeassistant/components/unifi/update.py +++ b/homeassistant/components/unifi/update.py @@ -4,7 +4,7 @@ from __future__ import annotations 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.config_entries import ConfigEntry 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 @@ from .entity import ( 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 668fe479e1f..8f8bcab8ede 100644 --- a/homeassistant/components/unifiprotect/binary_sensor.py +++ b/homeassistant/components/unifiprotect/binary_sensor.py @@ -621,3 +621,23 @@ class ProtectEventBinarySensor(EventEntityMixin, BinarySensorEntity): 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 3306743b707..bc93c156866 100644 --- a/homeassistant/components/unifiprotect/button.py +++ b/homeassistant/components/unifiprotect/button.py @@ -193,3 +193,17 @@ class ProtectButton(ProtectDeviceEntity, ButtonEntity): 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 d42e611be7e..28149d349c9 100644 --- a/homeassistant/components/unifiprotect/entity.py +++ b/homeassistant/components/unifiprotect/entity.py @@ -311,6 +311,8 @@ class ProtectNVREntity(ProtectDeviceEntity): 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 5f2f58ce98a..b63700720e6 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 c3f4e58e247..df5ea40d4a9 100644 --- a/homeassistant/components/unifiprotect/media_player.py +++ b/homeassistant/components/unifiprotect/media_player.py @@ -115,6 +115,26 @@ class ProtectMediaPlayer(ProtectDeviceEntity, MediaPlayerEntity): ) 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 247e401b2ca..08bc9f38527 100644 --- a/homeassistant/components/unifiprotect/number.py +++ b/homeassistant/components/unifiprotect/number.py @@ -268,3 +268,21 @@ class ProtectNumbers(ProtectDeviceEntity, NumberEntity): 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 6603a0543f8..00000000000 --- 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 26a03fb7967..7605be17fc9 100644 --- a/homeassistant/components/unifiprotect/select.py +++ b/homeassistant/components/unifiprotect/select.py @@ -349,9 +349,9 @@ class ProtectSelects(ProtectDeviceEntity, SelectEntity): 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 @@ class ProtectSelects(ProtectDeviceEntity, SelectEntity): _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 @@ class ProtectSelects(ProtectDeviceEntity, SelectEntity): 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 d842b13b015..756da49eb4d 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 ea2d8256cbe..f1e6185b010 100644 --- a/homeassistant/components/unifiprotect/switch.py +++ b/homeassistant/components/unifiprotect/switch.py @@ -420,21 +420,36 @@ class ProtectSwitch(ProtectDeviceEntity, SwitchEntity): 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 c221a10284a..00f345fd248 100644 --- a/homeassistant/components/universal/media_player.py +++ b/homeassistant/components/universal/media_player.py @@ -40,7 +40,6 @@ from homeassistant.components.media_player import ( SERVICE_PLAY_MEDIA, SERVICE_SELECT_SOUND_MODE, SERVICE_SELECT_SOURCE, - MediaPlayerDeviceClass, MediaPlayerEntity, MediaPlayerEntityFeature, MediaPlayerState, @@ -177,7 +176,7 @@ class UniversalMediaPlayer(MediaPlayerEntity): 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 @@ class UniversalMediaPlayer(MediaPlayerEntity): 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 4a71789423f..50e6d50bb4c 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 @@ class UpbLight(UpbAttachedEntity, LightEntity): 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 @@ class UpbLight(UpbAttachedEntity, LightEntity): 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 e23032e24fe..c9496ce8f7b 100644 --- a/homeassistant/components/update/__init__.py +++ b/homeassistant/components/update/__init__.py @@ -13,7 +13,7 @@ import voluptuous as vol 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 408937c4f31..00000000000 --- 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 95bb3e77966..1651dea6612 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.36.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 3cb119837d7..58979d7defb 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.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.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 a4aeeb3151b..2710d5166c2 100644 --- a/homeassistant/components/uptimerobot/binary_sensor.py +++ b/homeassistant/components/uptimerobot/binary_sensor.py @@ -10,8 +10,8 @@ from homeassistant.config_entries import ConfigEntry 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 00000000000..4c1d3ea2c78 --- /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 94710235ab7..15173a5e43c 100644 --- a/homeassistant/components/uptimerobot/diagnostics.py +++ b/homeassistant/components/uptimerobot/diagnostics.py @@ -8,8 +8,8 @@ from pyuptimerobot import UptimeRobotException 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 f9d4097fe40..4ae40bf4134 100644 --- a/homeassistant/components/uptimerobot/sensor.py +++ b/homeassistant/components/uptimerobot/sensor.py @@ -13,8 +13,8 @@ from homeassistant.const import EntityCategory 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 397d2085357..3406c9fe21a 100644 --- a/homeassistant/components/uptimerobot/switch.py +++ b/homeassistant/components/uptimerobot/switch.py @@ -14,8 +14,8 @@ from homeassistant.config_entries import ConfigEntry 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/vacuum/__init__.py b/homeassistant/components/vacuum/__init__.py index 8285e1d76d1..68d50d1c2fc 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 7dc7e9e0408..00000000000 --- 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 90045358136..b43ee39ed4e 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 @@ class VeluxEntity(Entity): 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 c924fe5c10b..48c09a2b3c2 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 @@ class VeluxCover(VeluxEntity, CoverEntity): @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 a92d495f6af..1416bcf376a 100644 --- a/homeassistant/components/venstar/__init__.py +++ b/homeassistant/components/venstar/__init__.py @@ -153,5 +153,5 @@ class VenstarEntity(CoordinatorEntity[VenstarDataUpdateCoordinator]): 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 39cbe0d3529..f3045fe49e8 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 57b47e6c742..82c7d187b88 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 164da079ac1..f58ae083f72 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 @@ class VeraThermostat(VeraDevice[veraApi.VeraThermostat], ClimateEntity): 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 fa017be475e..c76cd76ad19 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 @@ class VeraLight(VeraDevice[veraApi.VeraDimmer], LightEntity): 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 @@ class VeraLight(VeraDevice[veraApi.VeraDimmer], LightEntity): 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 50710030b8f..8994076ca31 100644 --- a/homeassistant/components/vera/lock.py +++ b/homeassistant/components/vera/lock.py @@ -7,7 +7,7 @@ import pyvera as veraApi 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 @@ class VeraLock(VeraDevice[veraApi.VeraLock], LockEntity): 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 @@ class VeraLock(VeraDevice[veraApi.VeraLock], LockEntity): 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 c1381f488dd..daa3a6fc530 100644 --- a/homeassistant/components/vera/scene.py +++ b/homeassistant/components/vera/scene.py @@ -37,7 +37,7 @@ class VeraScene(Scene): 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 @@ class VeraScene(Scene): """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 b493f9aac3d..58e350bd034 100644 --- a/homeassistant/components/vera/sensor.py +++ b/homeassistant/components/vera/sensor.py @@ -21,7 +21,6 @@ from homeassistant.const import ( ) 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 @@ class VeraSensor(VeraDevice[veraApi.VeraSensor], SensorEntity): 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 + 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 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 - 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 b146ed39ade..011f777b1b2 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 7c9e7057b0c..70c0505929d 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 0dd63218939..421a46bc2f6 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 b20a04b8a1c..f87f1cf3a8a 100644 --- a/homeassistant/components/vesync/const.py +++ b/homeassistant/components/vesync/const.py @@ -35,5 +35,10 @@ SKU_TO_BASE_DEVICE = { "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 e5347b204e6..87934ced81f 100644 --- a/homeassistant/components/vesync/fan.py +++ b/homeassistant/components/vesync/fan.py @@ -27,10 +27,12 @@ DEV_TYPE_TO_HA = { "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 @@ PRESET_MODES = { "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 @@ SPEED_RANGE = { # off is not included "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 89e8bec42d1..5aa76dc9962 100644 --- a/homeassistant/components/vicare/binary_sensor.py +++ b/homeassistant/components/vicare/binary_sensor.py @@ -196,23 +196,18 @@ class ViCareBinarySensor(BinarySensorEntity): 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 @@ class ViCareBinarySensor(BinarySensorEntity): 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 ac025ff37d1..7fd8cccd3a4 100644 --- a/homeassistant/components/vicare/button.py +++ b/homeassistant/components/vicare/button.py @@ -104,6 +104,13 @@ class ViCareButton(ButtonEntity): 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 @@ class ViCareButton(ButtonEntity): 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 d5beff4b268..a9188adc964 100644 --- a/homeassistant/components/vicare/climate.py +++ b/homeassistant/components/vicare/climate.py @@ -36,13 +36,7 @@ import homeassistant.helpers.config_validation as cv 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 @@ class ViCareClimate(ClimateEntity): _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 @@ class ViCareClimate(ClimateEntity): 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 @@ class ViCareClimate(ClimateEntity): 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 24f23b0da0a..d7ac7f25274 100644 --- a/homeassistant/components/vicare/sensor.py +++ b/homeassistant/components/vicare/sensor.py @@ -673,7 +673,6 @@ class ViCareSensor(SensorEntity): 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 @@ class ViCareSensor(SensorEntity): @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 @@ class ViCareSensor(SensorEntity): 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 c0d77dd46b6..3357d2e0a31 100644 --- a/homeassistant/components/vicare/water_heater.py +++ b/homeassistant/components/vicare/water_heater.py @@ -15,23 +15,12 @@ from homeassistant.components.water_heater import ( 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 @@ class ViCareWater(WaterHeaterEntity): 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 d694f4b93f8..0f5b3bc967c 100644 --- a/homeassistant/components/vizio/__init__.py +++ b/homeassistant/components/vizio/__init__.py @@ -107,7 +107,6 @@ class VizioAppsDataUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]] _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 87bc158331e..ef1df676a2d 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 @@ def catch_vlc_errors( 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 @@ class VlcDevice(MediaPlayerEntity): @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 @@ class VlcDevice(MediaPlayerEntity): 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 @@ class VlcDevice(MediaPlayerEntity): 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 @@ class VlcDevice(MediaPlayerEntity): @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 c1cf23d974f..816e9241739 100644 --- a/homeassistant/components/vodafone_station/__init__.py +++ b/homeassistant/components/vodafone_station/__init__.py @@ -7,7 +7,7 @@ from homeassistant.core import HomeAssistant from .const import DOMAIN from .coordinator import VodafoneStationRouter -PLATFORMS = [Platform.DEVICE_TRACKER] +PLATFORMS = [Platform.DEVICE_TRACKER, Platform.SENSOR, Platform.BUTTON] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/vodafone_station/button.py b/homeassistant/components/vodafone_station/button.py new file mode 100644 index 00000000000..7f93f8023ef --- /dev/null +++ b/homeassistant/components/vodafone_station/button.py @@ -0,0 +1,113 @@ +"""Vodafone Station buttons.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any, Final + +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 homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import _LOGGER, DOMAIN +from .coordinator import VodafoneStationRouter + + +@dataclass +class VodafoneStationBaseEntityDescriptionMixin: + """Mixin to describe a Button entity.""" + + press_action: Callable[[VodafoneStationRouter], Any] + is_suitable: Callable[[dict], bool] + + +@dataclass +class VodafoneStationEntityDescription( + ButtonEntityDescription, VodafoneStationBaseEntityDescriptionMixin +): + """Vodafone Station entity description.""" + + +BUTTON_TYPES: Final = ( + VodafoneStationEntityDescription( + key="reboot", + device_class=ButtonDeviceClass.RESTART, + entity_category=EntityCategory.CONFIG, + press_action=lambda coordinator: coordinator.api.restart_router(), + is_suitable=lambda _: True, + ), + VodafoneStationEntityDescription( + key="dsl_ready", + translation_key="dsl_reconnect", + device_class=ButtonDeviceClass.RESTART, + entity_category=EntityCategory.DIAGNOSTIC, + press_action=lambda coordinator: coordinator.api.restart_connection("dsl"), + is_suitable=lambda info: info.get("dsl_ready") == "1", + ), + VodafoneStationEntityDescription( + key="fiber_ready", + translation_key="fiber_reconnect", + device_class=ButtonDeviceClass.RESTART, + entity_category=EntityCategory.DIAGNOSTIC, + press_action=lambda coordinator: coordinator.api.restart_connection("fiber"), + is_suitable=lambda info: info.get("fiber_ready") == "1", + ), + VodafoneStationEntityDescription( + key="vf_internet_key_online_since", + translation_key="internet_key_reconnect", + device_class=ButtonDeviceClass.RESTART, + entity_category=EntityCategory.DIAGNOSTIC, + press_action=lambda coordinator: coordinator.api.restart_connection( + "internet_key" + ), + is_suitable=lambda info: info.get("vf_internet_key_online_since") != "", + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up entry.""" + _LOGGER.debug("Setting up Vodafone Station buttons") + + coordinator: VodafoneStationRouter = hass.data[DOMAIN][entry.entry_id] + + sensors_data = coordinator.data.sensors + + async_add_entities( + VodafoneStationSensorEntity(coordinator, sensor_descr) + for sensor_descr in BUTTON_TYPES + if sensor_descr.is_suitable(sensors_data) + ) + + +class VodafoneStationSensorEntity( + CoordinatorEntity[VodafoneStationRouter], ButtonEntity +): + """Representation of a Vodafone Station button.""" + + _attr_has_entity_name = True + entity_description: VodafoneStationEntityDescription + + def __init__( + self, + coordinator: VodafoneStationRouter, + description: VodafoneStationEntityDescription, + ) -> None: + """Initialize a Vodafone Station sensor.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_device_info = coordinator.device_info + self._attr_unique_id = f"{coordinator.serial_number}_{description.key}" + + async def async_press(self) -> None: + """Triggers the Shelly button press service.""" + await self.entity_description.press_action(self.coordinator) diff --git a/homeassistant/components/vodafone_station/config_flow.py b/homeassistant/components/vodafone_station/config_flow.py index e4a087f6903..45bb263d371 100644 --- a/homeassistant/components/vodafone_station/config_flow.py +++ b/homeassistant/components/vodafone_station/config_flow.py @@ -68,10 +68,14 @@ class VodafoneStationConfigFlow(ConfigFlow, domain=DOMAIN): try: info = await validate_input(self.hass, user_input) + except aiovodafone_exceptions.AlreadyLogged: + errors["base"] = "already_logged" except aiovodafone_exceptions.CannotConnect: errors["base"] = "cannot_connect" except aiovodafone_exceptions.CannotAuthenticate: errors["base"] = "invalid_auth" + except aiovodafone_exceptions.ModelNotSupported: + errors["base"] = "model_not_supported" except Exception: # pylint: disable=broad-except _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" @@ -99,6 +103,8 @@ class VodafoneStationConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is not None: try: await validate_input(self.hass, {**self.entry.data, **user_input}) + except aiovodafone_exceptions.AlreadyLogged: + errors["base"] = "already_logged" except aiovodafone_exceptions.CannotConnect: errors["base"] = "cannot_connect" except aiovodafone_exceptions.CannotAuthenticate: diff --git a/homeassistant/components/vodafone_station/const.py b/homeassistant/components/vodafone_station/const.py index 8d5a60afb60..c4828e19951 100644 --- a/homeassistant/components/vodafone_station/const.py +++ b/homeassistant/components/vodafone_station/const.py @@ -8,4 +8,5 @@ DOMAIN = "vodafone_station" 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 58079180bf8..fe1ff1889d5 100644 --- a/homeassistant/components/vodafone_station/coordinator.py +++ b/homeassistant/components/vodafone_station/coordinator.py @@ -8,6 +8,7 @@ from aiovodafone import VodafoneStationApi, VodafoneStationDevice, exceptions from homeassistant.components.device_tracker import DEFAULT_CONSIDER_HOME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util @@ -122,3 +123,22 @@ class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]): def signal_device_new(self) -> str: """Event specific per Vodafone Station entry to signal new device.""" return f"{DOMAIN}-device-new-{self._id}" + + @property + def serial_number(self) -> str: + """Device serial number.""" + return self.data.sensors["sys_serial_number"] + + @property + def device_info(self) -> DeviceInfo: + """Set device info.""" + sensors_data = self.data.sensors + return DeviceInfo( + configuration_url=self.api.base_url, + identifiers={(DOMAIN, self.serial_number)}, + name=f"Vodafone Station ({self.serial_number})", + manufacturer="Vodafone", + model=sensors_data.get("sys_model_name"), + hw_version=sensors_data["sys_hardware_version"], + sw_version=sensors_data["sys_firmware_version"], + ) diff --git a/homeassistant/components/vodafone_station/manifest.json b/homeassistant/components/vodafone_station/manifest.json index 68e7665b5ac..d37fed9564f 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.2.0"] + "requirements": ["aiovodafone==0.3.1"] } diff --git a/homeassistant/components/vodafone_station/sensor.py b/homeassistant/components/vodafone_station/sensor.py new file mode 100644 index 00000000000..ce2d3154de3 --- /dev/null +++ b/homeassistant/components/vodafone_station/sensor.py @@ -0,0 +1,204 @@ +"""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.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) + self.entity_description = description + self._attr_device_info = coordinator.device_info + self._attr_unique_id = f"{coordinator.serial_number}_{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 3c452133c28..aaaa27a3614 100644 --- a/homeassistant/components/vodafone_station/strings.json +++ b/homeassistant/components/vodafone_station/strings.json @@ -12,22 +12,55 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", - "password": "[%key:common::config_flow::data::password%]", - "ssl": "[%key:common::config_flow::data::ssl%]" + "password": "[%key:common::config_flow::data::password%]" } } }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "already_logged": "User already logged-in, please try again later.", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "model_not_supported": "The device model is currently unsupported.", "unknown": "[%key:common::config_flow::error::unknown%]" }, "error": { + "already_logged": "User already logged-in, please try again later.", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "model_not_supported": "The device model is currently unsupported.", "unknown": "[%key:common::config_flow::error::unknown%]" } + }, + "entity": { + "button": { + "dsl_reconnect": { "name": "DSL reconnect" }, + "fiber_reconnect": { "name": "Fiber reconnect" }, + "internet_key_reconnect": { "name": "Internet key reconnect" } + }, + "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/voip/voip.py b/homeassistant/components/voip/voip.py index efa62e0e8f4..6ea97268684 100644 --- a/homeassistant/components/voip/voip.py +++ b/homeassistant/components/voip/voip.py @@ -29,8 +29,11 @@ from homeassistant.components.assist_pipeline import ( select as pipeline_select, ) from homeassistant.components.assist_pipeline.vad import ( + AudioBuffer, VadSensitivity, + VoiceActivityDetector, VoiceCommandSegmenter, + WebRtcVad, ) from homeassistant.const import __version__ from homeassistant.core import Context, HomeAssistant @@ -225,11 +228,13 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol): try: # Wait for speech before starting pipeline segmenter = VoiceCommandSegmenter(silence_seconds=self.silence_seconds) + vad = WebRtcVad() chunk_buffer: deque[bytes] = deque( maxlen=self.buffered_chunks_before_speech, ) speech_detected = await self._wait_for_speech( segmenter, + vad, chunk_buffer, ) if not speech_detected: @@ -243,6 +248,7 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol): try: async for chunk in self._segment_audio( segmenter, + vad, chunk_buffer, ): yield chunk @@ -306,6 +312,7 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol): async def _wait_for_speech( self, segmenter: VoiceCommandSegmenter, + vad: VoiceActivityDetector, chunk_buffer: MutableSequence[bytes], ): """Buffer audio chunks until speech is detected. @@ -317,12 +324,18 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol): async with asyncio.timeout(self.audio_timeout): chunk = await self._audio_queue.get() + assert vad.samples_per_chunk is not None + vad_buffer = AudioBuffer(vad.samples_per_chunk * WIDTH) + while chunk: chunk_buffer.append(chunk) - segmenter.process(chunk) + segmenter.process_with_vad(chunk, vad, vad_buffer) if segmenter.in_command: # Buffer until command starts + if len(vad_buffer) > 0: + chunk_buffer.append(vad_buffer.bytes()) + return True async with asyncio.timeout(self.audio_timeout): @@ -333,6 +346,7 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol): async def _segment_audio( self, segmenter: VoiceCommandSegmenter, + vad: VoiceActivityDetector, chunk_buffer: Sequence[bytes], ) -> AsyncIterable[bytes]: """Yield audio chunks until voice command has finished.""" @@ -345,8 +359,11 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol): async with asyncio.timeout(self.audio_timeout): chunk = await self._audio_queue.get() + assert vad.samples_per_chunk is not None + vad_buffer = AudioBuffer(vad.samples_per_chunk * WIDTH) + while chunk: - if not segmenter.process(chunk): + if not segmenter.process_with_vad(chunk, vad, vad_buffer): # Voice command is finished break diff --git a/homeassistant/components/volumio/media_player.py b/homeassistant/components/volumio/media_player.py index d207e36e3c9..a11ea62e355 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 @@ class Volumio(MediaPlayerEntity): 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 @@ class Volumio(MediaPlayerEntity): 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 b2b270e3422..07a0510f482 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/w800rf32/manifest.json b/homeassistant/components/w800rf32/manifest.json index e76835abcbe..769eb96b3c0 100644 --- a/homeassistant/components/w800rf32/manifest.json +++ b/homeassistant/components/w800rf32/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/w800rf32", "iot_class": "local_push", "loggers": ["W800rf32"], - "requirements": ["pyW800rf32==0.1"] + "requirements": ["pyW800rf32==0.4"] } diff --git a/homeassistant/components/wake_word/__init__.py b/homeassistant/components/wake_word/__init__.py index b308cf98912..6c55bd8e7e7 100644 --- a/homeassistant/components/wake_word/__init__.py +++ b/homeassistant/components/wake_word/__init__.py @@ -6,6 +6,9 @@ from collections.abc import AsyncIterable import logging from typing import final +import voluptuous as vol + +from homeassistant.components import websocket_api from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, EntityCategory from homeassistant.core import HomeAssistant, callback @@ -19,7 +22,7 @@ from .const import DOMAIN from .models import DetectionResult, WakeWord __all__ = [ - "async_default_engine", + "async_default_entity", "async_get_wake_word_detection_entity", "DetectionResult", "DOMAIN", @@ -33,8 +36,8 @@ CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) @callback -def async_default_engine(hass: HomeAssistant) -> str | None: - """Return the domain or entity id of the default engine.""" +def async_default_entity(hass: HomeAssistant) -> str | None: + """Return the entity id of the default engine.""" return next(iter(hass.states.async_entity_ids(DOMAIN)), None) @@ -49,7 +52,9 @@ def async_get_wake_word_detection_entity( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up STT.""" + """Set up wake word.""" + websocket_api.async_register_command(hass, websocket_entity_info) + component = hass.data[DOMAIN] = EntityComponent(_LOGGER, DOMAIN, hass) component.register_shutdown() @@ -88,7 +93,7 @@ class WakeWordDetectionEntity(RestoreEntity): @abstractmethod async def _async_process_audio_stream( - self, stream: AsyncIterable[tuple[bytes, int]] + self, stream: AsyncIterable[tuple[bytes, int]], wake_word_id: str | None ) -> DetectionResult | None: """Try to detect wake word(s) in an audio stream with timestamps. @@ -96,13 +101,13 @@ class WakeWordDetectionEntity(RestoreEntity): """ async def async_process_audio_stream( - self, stream: AsyncIterable[tuple[bytes, int]] + self, stream: AsyncIterable[tuple[bytes, int]], wake_word_id: str | None ) -> DetectionResult | None: """Try to detect wake word(s) in an audio stream with timestamps. Audio must be 16Khz sample rate with 16-bit mono PCM samples. """ - result = await self._async_process_audio_stream(stream) + result = await self._async_process_audio_stream(stream, wake_word_id) if result is not None: # Update last detected only when there is a detection self.__last_detected = dt_util.utcnow().isoformat() @@ -120,3 +125,29 @@ class WakeWordDetectionEntity(RestoreEntity): and state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN) ): self.__last_detected = state.state + + +@websocket_api.websocket_command( + { + "type": "wake_word/info", + vol.Required("entity_id"): cv.entity_domain(DOMAIN), + } +) +@callback +def websocket_entity_info( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict +) -> None: + """Get info about wake word entity.""" + component: EntityComponent[WakeWordDetectionEntity] = hass.data[DOMAIN] + entity = component.get_entity(msg["entity_id"]) + + if entity is None: + connection.send_error( + msg["id"], websocket_api.const.ERR_NOT_FOUND, "Entity not found" + ) + return + + connection.send_result( + msg["id"], + {"wake_words": entity.supported_wake_words}, + ) diff --git a/homeassistant/components/wake_word/models.py b/homeassistant/components/wake_word/models.py index 1ea154f1393..8e0699d97d0 100644 --- a/homeassistant/components/wake_word/models.py +++ b/homeassistant/components/wake_word/models.py @@ -6,7 +6,7 @@ from dataclasses import dataclass class WakeWord: """Wake word model.""" - ww_id: str + id: str name: str @@ -14,7 +14,7 @@ class WakeWord: class DetectionResult: """Result of wake word detection.""" - ww_id: str + wake_word_id: str """Id of detected wake word""" timestamp: int | None diff --git a/homeassistant/components/wallbox/__init__.py b/homeassistant/components/wallbox/__init__.py index 9b27b9c4bd1..4db217d0a54 100644 --- a/homeassistant/components/wallbox/__init__.py +++ b/homeassistant/components/wallbox/__init__.py @@ -246,6 +246,8 @@ class InvalidAuth(HomeAssistantError): class WallboxEntity(CoordinatorEntity[WallboxCoordinator]): """Defines a base Wallbox entity.""" + _attr_has_entity_name = True + @property def device_info(self) -> DeviceInfo: """Return device information about this Wallbox device.""" @@ -256,7 +258,7 @@ class WallboxEntity(CoordinatorEntity[WallboxCoordinator]): self.coordinator.data[CHARGER_DATA_KEY][CHARGER_SERIAL_NUMBER_KEY], ) }, - name=f"Wallbox - {self.coordinator.data[CHARGER_NAME_KEY]}", + name=f"Wallbox {self.coordinator.data[CHARGER_NAME_KEY]}", manufacturer="Wallbox", model=self.coordinator.data[CHARGER_DATA_KEY][CHARGER_PART_NUMBER_KEY], sw_version=self.coordinator.data[CHARGER_DATA_KEY][CHARGER_SOFTWARE_KEY][ diff --git a/homeassistant/components/wallbox/lock.py b/homeassistant/components/wallbox/lock.py index 7b5dca58010..04a587ae34d 100644 --- a/homeassistant/components/wallbox/lock.py +++ b/homeassistant/components/wallbox/lock.py @@ -20,7 +20,7 @@ from .const import ( LOCK_TYPES: dict[str, LockEntityDescription] = { CHARGER_LOCKED_UNLOCKED_KEY: LockEntityDescription( key=CHARGER_LOCKED_UNLOCKED_KEY, - name="Locked/Unlocked", + translation_key="lock", ), } @@ -42,7 +42,7 @@ async def async_setup_entry( async_add_entities( [ - WallboxLock(coordinator, entry, description) + WallboxLock(coordinator, description) for ent in coordinator.data if (description := LOCK_TYPES.get(ent)) ] @@ -55,14 +55,12 @@ class WallboxLock(WallboxEntity, LockEntity): def __init__( self, coordinator: WallboxCoordinator, - entry: ConfigEntry, description: LockEntityDescription, ) -> None: """Initialize a Wallbox lock.""" super().__init__(coordinator) self.entity_description = description - self._attr_name = f"{entry.title} {description.name}" self._attr_unique_id = f"{description.key}-{coordinator.data[CHARGER_DATA_KEY][CHARGER_SERIAL_NUMBER_KEY]}" @property diff --git a/homeassistant/components/wallbox/number.py b/homeassistant/components/wallbox/number.py index 58d4a5e6afb..b8ce331146d 100644 --- a/homeassistant/components/wallbox/number.py +++ b/homeassistant/components/wallbox/number.py @@ -33,7 +33,7 @@ class WallboxNumberEntityDescription(NumberEntityDescription): NUMBER_TYPES: dict[str, WallboxNumberEntityDescription] = { CHARGER_MAX_CHARGING_CURRENT_KEY: WallboxNumberEntityDescription( key=CHARGER_MAX_CHARGING_CURRENT_KEY, - name="Max. Charging Current", + translation_key="maximum_charging_current", ), } @@ -77,7 +77,6 @@ class WallboxNumber(WallboxEntity, NumberEntity): super().__init__(coordinator) self.entity_description = description self._coordinator = coordinator - self._attr_name = f"{entry.title} {description.name}" self._attr_unique_id = f"{description.key}-{coordinator.data[CHARGER_DATA_KEY][CHARGER_SERIAL_NUMBER_KEY]}" self._is_bidirectional = ( coordinator.data[CHARGER_DATA_KEY][CHARGER_PART_NUMBER_KEY][0:3] diff --git a/homeassistant/components/wallbox/sensor.py b/homeassistant/components/wallbox/sensor.py index afd2b13f790..56d9e0be735 100644 --- a/homeassistant/components/wallbox/sensor.py +++ b/homeassistant/components/wallbox/sensor.py @@ -60,7 +60,7 @@ class WallboxSensorEntityDescription(SensorEntityDescription): SENSOR_TYPES: dict[str, WallboxSensorEntityDescription] = { CHARGER_CHARGING_POWER_KEY: WallboxSensorEntityDescription( key=CHARGER_CHARGING_POWER_KEY, - name="Charging Power", + translation_key=CHARGER_CHARGING_POWER_KEY, precision=2, native_unit_of_measurement=UnitOfPower.KILO_WATT, device_class=SensorDeviceClass.POWER, @@ -68,7 +68,7 @@ SENSOR_TYPES: dict[str, WallboxSensorEntityDescription] = { ), CHARGER_MAX_AVAILABLE_POWER_KEY: WallboxSensorEntityDescription( key=CHARGER_MAX_AVAILABLE_POWER_KEY, - name="Max Available Power", + translation_key=CHARGER_MAX_AVAILABLE_POWER_KEY, precision=0, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, @@ -76,15 +76,15 @@ SENSOR_TYPES: dict[str, WallboxSensorEntityDescription] = { ), CHARGER_CHARGING_SPEED_KEY: WallboxSensorEntityDescription( key=CHARGER_CHARGING_SPEED_KEY, + translation_key=CHARGER_CHARGING_SPEED_KEY, icon="mdi:speedometer", - name="Charging Speed", precision=0, state_class=SensorStateClass.MEASUREMENT, ), CHARGER_ADDED_RANGE_KEY: WallboxSensorEntityDescription( key=CHARGER_ADDED_RANGE_KEY, + translation_key=CHARGER_ADDED_RANGE_KEY, icon="mdi:map-marker-distance", - name="Added Range", precision=0, native_unit_of_measurement=UnitOfLength.KILOMETERS, device_class=SensorDeviceClass.DISTANCE, @@ -92,7 +92,7 @@ SENSOR_TYPES: dict[str, WallboxSensorEntityDescription] = { ), CHARGER_ADDED_ENERGY_KEY: WallboxSensorEntityDescription( key=CHARGER_ADDED_ENERGY_KEY, - name="Added Energy", + translation_key=CHARGER_ADDED_ENERGY_KEY, precision=2, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, @@ -100,7 +100,7 @@ SENSOR_TYPES: dict[str, WallboxSensorEntityDescription] = { ), CHARGER_ADDED_DISCHARGED_ENERGY_KEY: WallboxSensorEntityDescription( key=CHARGER_ADDED_DISCHARGED_ENERGY_KEY, - name="Discharged Energy", + translation_key=CHARGER_ADDED_DISCHARGED_ENERGY_KEY, precision=2, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, @@ -108,44 +108,44 @@ SENSOR_TYPES: dict[str, WallboxSensorEntityDescription] = { ), CHARGER_COST_KEY: WallboxSensorEntityDescription( key=CHARGER_COST_KEY, + translation_key=CHARGER_COST_KEY, icon="mdi:ev-station", - name="Cost", state_class=SensorStateClass.TOTAL_INCREASING, ), CHARGER_STATE_OF_CHARGE_KEY: WallboxSensorEntityDescription( key=CHARGER_STATE_OF_CHARGE_KEY, - name="State of Charge", + translation_key=CHARGER_STATE_OF_CHARGE_KEY, native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, state_class=SensorStateClass.MEASUREMENT, ), CHARGER_CURRENT_MODE_KEY: WallboxSensorEntityDescription( key=CHARGER_CURRENT_MODE_KEY, + translation_key=CHARGER_CURRENT_MODE_KEY, icon="mdi:ev-station", - name="Current Mode", ), CHARGER_DEPOT_PRICE_KEY: WallboxSensorEntityDescription( key=CHARGER_DEPOT_PRICE_KEY, + translation_key=CHARGER_DEPOT_PRICE_KEY, icon="mdi:ev-station", - name="Depot Price", precision=2, state_class=SensorStateClass.MEASUREMENT, ), CHARGER_ENERGY_PRICE_KEY: WallboxSensorEntityDescription( key=CHARGER_ENERGY_PRICE_KEY, + translation_key=CHARGER_ENERGY_PRICE_KEY, icon="mdi:ev-station", - name="Energy Price", precision=2, state_class=SensorStateClass.MEASUREMENT, ), CHARGER_STATUS_DESCRIPTION_KEY: WallboxSensorEntityDescription( key=CHARGER_STATUS_DESCRIPTION_KEY, + translation_key=CHARGER_STATUS_DESCRIPTION_KEY, icon="mdi:ev-station", - name="Status Description", ), CHARGER_MAX_CHARGING_CURRENT_KEY: WallboxSensorEntityDescription( key=CHARGER_MAX_CHARGING_CURRENT_KEY, - name="Max. Charging Current", + translation_key=CHARGER_MAX_CHARGING_CURRENT_KEY, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, @@ -161,7 +161,7 @@ async def async_setup_entry( async_add_entities( [ - WallboxSensor(coordinator, entry, description) + WallboxSensor(coordinator, description) for ent in coordinator.data if (description := SENSOR_TYPES.get(ent)) ] @@ -176,13 +176,11 @@ class WallboxSensor(WallboxEntity, SensorEntity): def __init__( self, coordinator: WallboxCoordinator, - entry: ConfigEntry, description: WallboxSensorEntityDescription, ) -> None: """Initialize a Wallbox sensor.""" super().__init__(coordinator) self.entity_description = description - self._attr_name = f"{entry.title} {description.name}" self._attr_unique_id = f"{description.key}-{coordinator.data[CHARGER_DATA_KEY][CHARGER_SERIAL_NUMBER_KEY]}" @property diff --git a/homeassistant/components/wallbox/strings.json b/homeassistant/components/wallbox/strings.json index 4cde9c6d255..69db4bb97e3 100644 --- a/homeassistant/components/wallbox/strings.json +++ b/homeassistant/components/wallbox/strings.json @@ -25,5 +25,63 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } + }, + "entity": { + "lock": { + "lock": { + "name": "[%key:component::lock::title%]" + } + }, + "number": { + "maximum_charging_current": { + "name": "Maximum charging current" + } + }, + "sensor": { + "charging_power": { + "name": "Charging power" + }, + "max_available_power": { + "name": "Max available power" + }, + "charging_speed": { + "name": "Charging speed" + }, + "added_range": { + "name": "Added range" + }, + "added_energy": { + "name": "Added energy" + }, + "added_discharged_energy": { + "name": "Discharged energy" + }, + "cost": { + "name": "Cost" + }, + "state_of_charge": { + "name": "State of charge" + }, + "current_mode": { + "name": "Current mode" + }, + "depot_price": { + "name": "Depot price" + }, + "energy_price": { + "name": "Energy price" + }, + "status_description": { + "name": "Status description" + }, + "max_charging_current": { + "name": "Max charging current" + } + }, + "switch": { + "pause_resume": { + "name": "Pause/resume" + } + } } } diff --git a/homeassistant/components/wallbox/switch.py b/homeassistant/components/wallbox/switch.py index 7a0736f59e7..b101ffe1c09 100644 --- a/homeassistant/components/wallbox/switch.py +++ b/homeassistant/components/wallbox/switch.py @@ -21,7 +21,7 @@ from .const import ( SWITCH_TYPES: dict[str, SwitchEntityDescription] = { CHARGER_PAUSE_RESUME_KEY: SwitchEntityDescription( key=CHARGER_PAUSE_RESUME_KEY, - name="Pause/Resume", + translation_key="pause_resume", ), } @@ -32,7 +32,7 @@ async def async_setup_entry( """Create wallbox sensor entities in HASS.""" coordinator: WallboxCoordinator = hass.data[DOMAIN][entry.entry_id] async_add_entities( - [WallboxSwitch(coordinator, entry, SWITCH_TYPES[CHARGER_PAUSE_RESUME_KEY])] + [WallboxSwitch(coordinator, SWITCH_TYPES[CHARGER_PAUSE_RESUME_KEY])] ) @@ -42,13 +42,11 @@ class WallboxSwitch(WallboxEntity, SwitchEntity): def __init__( self, coordinator: WallboxCoordinator, - entry: ConfigEntry, description: SwitchEntityDescription, ) -> None: """Initialize a Wallbox switch.""" super().__init__(coordinator) self.entity_description = description - self._attr_name = f"{entry.title} {description.name}" self._attr_unique_id = f"{description.key}-{coordinator.data[CHARGER_DATA_KEY][CHARGER_SERIAL_NUMBER_KEY]}" @property diff --git a/homeassistant/components/waqi/__init__.py b/homeassistant/components/waqi/__init__.py index 5cacd9e5e1b..d3cf1af21a2 100644 --- a/homeassistant/components/waqi/__init__.py +++ b/homeassistant/components/waqi/__init__.py @@ -1 +1,53 @@ -"""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 +import homeassistant.helpers.entity_registry as er + +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.""" + + await _migrate_unique_ids(hass, 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 + + +async def _migrate_unique_ids(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Migrate pre-config flow unique ids.""" + entity_registry = er.async_get(hass) + registry_entries = er.async_entries_for_config_entry( + entity_registry, entry.entry_id + ) + for reg_entry in registry_entries: + if isinstance(reg_entry.unique_id, int): + entity_registry.async_update_entity( + reg_entry.entity_id, new_unique_id=f"{reg_entry.unique_id}_air_quality" + ) diff --git a/homeassistant/components/waqi/config_flow.py b/homeassistant/components/waqi/config_flow.py new file mode 100644 index 00000000000..8404b425678 --- /dev/null +++ b/homeassistant/components/waqi/config_flow.py @@ -0,0 +1,216 @@ +"""Config flow for World Air Quality Index (WAQI) integration.""" +from __future__ import annotations + +from collections.abc import Awaitable, Callable +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_METHOD, + 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, + SelectSelector, + SelectSelectorConfig, +) +from homeassistant.helpers.typing import ConfigType + +from .const import CONF_STATION_NUMBER, DOMAIN, ISSUE_PLACEHOLDER + +_LOGGER = logging.getLogger(__name__) + +CONF_MAP = "map" + + +class WAQIConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for World Air Quality Index (WAQI).""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize config flow.""" + self.data: dict[str, Any] = {} + + 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]) + try: + await waqi_client.get_by_ip() + 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: + self.data = user_input + if user_input[CONF_METHOD] == CONF_MAP: + return await self.async_step_map() + return await self.async_step_station_number() + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_API_KEY): str, + vol.Required(CONF_METHOD): SelectSelector( + SelectSelectorConfig( + options=[CONF_MAP, CONF_STATION_NUMBER], + translation_key="method", + ) + ), + } + ), + errors=errors, + ) + + async def _async_base_step( + self, + step_id: str, + method: Callable[[WAQIClient, dict[str, Any]], Awaitable[WAQIAirQuality]], + data_schema: vol.Schema, + user_input: dict[str, Any] | None = None, + ) -> FlowResult: + 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(self.data[CONF_API_KEY]) + try: + measuring_station = await method(waqi_client, user_input) + except WAQIConnectionError: + errors["base"] = "cannot_connect" + except Exception as exc: # pylint: disable=broad-except + _LOGGER.exception(exc) + errors["base"] = "unknown" + else: + return await self._async_create_entry(measuring_station) + return self.async_show_form( + step_id=step_id, data_schema=data_schema, errors=errors + ) + + async def async_step_map( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Add measuring station via map.""" + return await self._async_base_step( + CONF_MAP, + lambda waqi_client, data: waqi_client.get_by_coordinates( + data[CONF_LOCATION][CONF_LATITUDE], data[CONF_LOCATION][CONF_LONGITUDE] + ), + self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Required( + CONF_LOCATION, + ): LocationSelector(), + } + ), + { + CONF_LOCATION: { + CONF_LATITUDE: self.hass.config.latitude, + CONF_LONGITUDE: self.hass.config.longitude, + } + }, + ), + user_input, + ) + + async def async_step_station_number( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Add measuring station via station number.""" + return await self._async_base_step( + CONF_STATION_NUMBER, + lambda waqi_client, data: waqi_client.get_by_station_number( + data[CONF_STATION_NUMBER] + ), + vol.Schema( + { + vol.Required( + CONF_STATION_NUMBER, + ): int, + } + ), + user_input, + ) + + async def _async_create_entry( + self, measuring_station: WAQIAirQuality + ) -> FlowResult: + 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: self.data[CONF_API_KEY], + CONF_STATION_NUMBER: measuring_station.station_id, + }, + ) + + 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 00000000000..2847a29b8ad --- /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 00000000000..b7beef8fda9 --- /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 2022558a500..a866dc2c902 100644 --- a/homeassistant/components/waqi/manifest.json +++ b/homeassistant/components/waqi/manifest.json @@ -1,9 +1,10 @@ { "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"], - "requirements": ["aiowaqi==0.2.1"] + "loggers": ["aiowaqi"], + "requirements": ["aiowaqi==2.0.0"] } diff --git a/homeassistant/components/waqi/sensor.py b/homeassistant/components/waqi/sensor.py index 51b9acb8e59..62170b329f4 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 @@ from homeassistant.components.sensor import ( 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.exceptions import PlatformNotReady 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 @@ ATTR_ICON = "mdi:cloud" 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, + ) -class WaqiSensor(SensorEntity): +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(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 = f"{coordinator.data.station_id}_air_quality" @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 = {} + try: + attrs[ATTR_ATTRIBUTION] = " and ".join( + [ATTRIBUTION] + + [ + attribution.name + for attribution in self.coordinator.data.attributions + ] + ) - 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.coordinator.data.measured_at + attrs[ATTR_DOMINENTPOL] = self.coordinator.data.dominant_pollutant - attrs[ATTR_TIME] = self._data.measured_at - attrs[ATTR_DOMINENTPOL] = self._data.dominant_pollutant + iaqi = self.coordinator.data.extended_air_quality - 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 + 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 00000000000..46031a3072b --- /dev/null +++ b/homeassistant/components/waqi/strings.json @@ -0,0 +1,57 @@ +{ + "config": { + "step": { + "user": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]", + "method": "How do you want to select a measuring station?" + } + }, + "map": { + "description": "Select a location to get the closest measuring station.", + "data": { + "location": "[%key:common::config_flow::data::location%]" + } + }, + "station_number": { + "data": { + "station_number": "Measuring station number" + } + } + }, + "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%]" + } + }, + "selector": { + "method": { + "options": { + "map": "Select nearest from point on the map", + "station_number": "Enter a station number" + } + } + }, + "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 b31d1306c55..9e796092f6a 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 d76b96936fa..00000000000 --- 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/sensor.py b/homeassistant/components/waze_travel_time/sensor.py index 2b3010a39cb..bf3544de8a9 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 @@ _LOGGER = logging.getLogger(__name__) 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 @@ class WazeTravelTime(SensorEntity): 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 0d72dbb825e..4ec9ea91f89 100644 --- a/homeassistant/components/weather/__init__.py +++ b/homeassistant/components/weather/__init__.py @@ -264,6 +264,8 @@ class PostInit(metaclass=PostInitMeta): 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 1c887ea5202..00000000000 --- 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/weatherflow/__init__.py b/homeassistant/components/weatherflow/__init__.py new file mode 100644 index 00000000000..c64450babe7 --- /dev/null +++ b/homeassistant/components/weatherflow/__init__.py @@ -0,0 +1,77 @@ +"""Get data from Smart Weather station via UDP.""" +from __future__ import annotations + +from pyweatherflowudp.client import EVENT_DEVICE_DISCOVERED, WeatherFlowListener +from pyweatherflowudp.device import EVENT_LOAD_COMPLETE, WeatherFlowDevice +from pyweatherflowudp.errors import ListenerError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform +from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.start import async_at_started + +from .const import DOMAIN, LOGGER, format_dispatch_call + +PLATFORMS = [ + Platform.SENSOR, +] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up WeatherFlow from a config entry.""" + + client = WeatherFlowListener() + + @callback + def _async_device_discovered(device: WeatherFlowDevice) -> None: + LOGGER.debug("Found a device: %s", device) + + @callback + def _async_add_device_if_started(device: WeatherFlowDevice): + async_at_started( + hass, + callback( + lambda _: async_dispatcher_send( + hass, format_dispatch_call(entry), device + ) + ), + ) + + entry.async_on_unload( + device.on( + EVENT_LOAD_COMPLETE, + lambda _: _async_add_device_if_started(device), + ) + ) + + entry.async_on_unload(client.on(EVENT_DEVICE_DISCOVERED, _async_device_discovered)) + + try: + await client.start_listening() + except ListenerError as ex: + raise ConfigEntryNotReady from ex + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = client + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + async def _async_handle_ha_shutdown(event: Event) -> None: + """Handle HA shutdown.""" + await client.stop_listening() + + entry.async_on_unload( + hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, _async_handle_ha_shutdown) + ) + + 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): + client: WeatherFlowListener = hass.data[DOMAIN].pop(entry.entry_id, None) + if client: + await client.stop_listening() + + return unload_ok diff --git a/homeassistant/components/weatherflow/config_flow.py b/homeassistant/components/weatherflow/config_flow.py new file mode 100644 index 00000000000..5ce737810b0 --- /dev/null +++ b/homeassistant/components/weatherflow/config_flow.py @@ -0,0 +1,75 @@ +"""Config flow for WeatherFlow.""" +from __future__ import annotations + +import asyncio +from asyncio import Future +from asyncio.exceptions import CancelledError +from typing import Any + +from pyweatherflowudp.client import EVENT_DEVICE_DISCOVERED, WeatherFlowListener +from pyweatherflowudp.errors import AddressInUseError, EndpointError, ListenerError + +from homeassistant import config_entries +from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult + +from .const import ( + DOMAIN, + ERROR_MSG_ADDRESS_IN_USE, + ERROR_MSG_CANNOT_CONNECT, + ERROR_MSG_NO_DEVICE_FOUND, +) + + +async def _async_can_discover_devices() -> bool: + """Return if there are devices that can be discovered.""" + future_event: Future[None] = asyncio.get_running_loop().create_future() + + @callback + def _async_found(_): + """Handle a discovered device - only need to do this once so.""" + + if not future_event.done(): + future_event.set_result(None) + + async with WeatherFlowListener() as client, asyncio.timeout(10): + try: + client.on(EVENT_DEVICE_DISCOVERED, _async_found) + await future_event + except asyncio.TimeoutError: + return False + + return True + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for WeatherFlow.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initialized by the user.""" + + # Only allow a single instance of integration since the listener + # will pick up all devices on the network and we don't want to + # create multiple entries. + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + found = False + errors = {} + try: + found = await _async_can_discover_devices() + except AddressInUseError: + errors["base"] = ERROR_MSG_ADDRESS_IN_USE + except (ListenerError, EndpointError, CancelledError): + errors["base"] = ERROR_MSG_CANNOT_CONNECT + + if not found and not errors: + errors["base"] = ERROR_MSG_NO_DEVICE_FOUND + + if errors: + return self.async_show_form(step_id="user", errors=errors) + + return self.async_create_entry(title="WeatherFlow", data={}) diff --git a/homeassistant/components/weatherflow/const.py b/homeassistant/components/weatherflow/const.py new file mode 100644 index 00000000000..fdacc6ef1eb --- /dev/null +++ b/homeassistant/components/weatherflow/const.py @@ -0,0 +1,18 @@ +"""Constants for the WeatherFlow integration.""" + +import logging + +from homeassistant.config_entries import ConfigEntry + +DOMAIN = "weatherflow" +LOGGER = logging.getLogger(__package__) + + +def format_dispatch_call(config_entry: ConfigEntry) -> str: + """Construct a dispatch call from a ConfigEntry.""" + return f"{config_entry.domain}_{config_entry.entry_id}_add" + + +ERROR_MSG_ADDRESS_IN_USE = "address_in_use" +ERROR_MSG_CANNOT_CONNECT = "cannot_connect" +ERROR_MSG_NO_DEVICE_FOUND = "no_device_found" diff --git a/homeassistant/components/weatherflow/manifest.json b/homeassistant/components/weatherflow/manifest.json new file mode 100644 index 00000000000..3c34250652d --- /dev/null +++ b/homeassistant/components/weatherflow/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "weatherflow", + "name": "WeatherFlow", + "codeowners": ["@natekspencer", "@jeeftor"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/weatherflow", + "integration_type": "hub", + "iot_class": "local_push", + "loggers": ["pyweatherflowudp"], + "requirements": ["pyweatherflowudp==1.4.3"] +} diff --git a/homeassistant/components/weatherflow/sensor.py b/homeassistant/components/weatherflow/sensor.py new file mode 100644 index 00000000000..dfc8e585f1b --- /dev/null +++ b/homeassistant/components/weatherflow/sensor.py @@ -0,0 +1,386 @@ +"""Sensors for the weatherflow integration.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass, field +from datetime import datetime +from enum import Enum + +from pyweatherflowudp.const import EVENT_RAPID_WIND +from pyweatherflowudp.device import ( + EVENT_OBSERVATION, + EVENT_STATUS_UPDATE, + WeatherFlowDevice, +) + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + DEGREE, + LIGHT_LUX, + PERCENTAGE, + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + UV_INDEX, + EntityCategory, + UnitOfElectricPotential, + UnitOfIrradiance, + UnitOfLength, + UnitOfPrecipitationDepth, + UnitOfPressure, + UnitOfSpeed, + UnitOfTemperature, + UnitOfVolumetricFlux, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.util.unit_system import METRIC_SYSTEM + +from .const import DOMAIN, LOGGER, format_dispatch_call + + +@dataclass +class WeatherFlowSensorRequiredKeysMixin: + """Mixin for required keys.""" + + raw_data_conv_fn: Callable[[WeatherFlowDevice], datetime | StateType] + + +def precipitation_raw_conversion_fn(raw_data: Enum): + """Parse parse precipitation type.""" + if raw_data.name.lower() == "unknown": + return None + return raw_data.name.lower() + + +@dataclass +class WeatherFlowSensorEntityDescription( + SensorEntityDescription, WeatherFlowSensorRequiredKeysMixin +): + """Describes WeatherFlow sensor entity.""" + + event_subscriptions: list[str] = field(default_factory=lambda: [EVENT_OBSERVATION]) + imperial_suggested_unit: None | str = None + + def get_native_value(self, device: WeatherFlowDevice) -> datetime | StateType: + """Return the parsed sensor value.""" + raw_sensor_data = getattr(device, self.key) + return self.raw_data_conv_fn(raw_sensor_data) + + +SENSORS: tuple[WeatherFlowSensorEntityDescription, ...] = ( + WeatherFlowSensorEntityDescription( + key="air_density", + translation_key="air_density", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=3, + raw_data_conv_fn=lambda raw_data: raw_data.m * 1000000, + ), + WeatherFlowSensorEntityDescription( + key="air_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, + ), + WeatherFlowSensorEntityDescription( + key="dew_point_temperature", + translation_key="dew_point", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, + ), + WeatherFlowSensorEntityDescription( + key="feels_like_temperature", + translation_key="feels_like", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, + ), + WeatherFlowSensorEntityDescription( + key="wet_bulb_temperature", + translation_key="wet_bulb_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, + ), + WeatherFlowSensorEntityDescription( + key="battery", + translation_key="battery_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, + ), + WeatherFlowSensorEntityDescription( + key="illuminance", + native_unit_of_measurement=LIGHT_LUX, + device_class=SensorDeviceClass.ILLUMINANCE, + state_class=SensorStateClass.MEASUREMENT, + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, + ), + WeatherFlowSensorEntityDescription( + key="lightning_strike_average_distance", + icon="mdi:lightning-bolt", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.DISTANCE, + native_unit_of_measurement=UnitOfLength.KILOMETERS, + translation_key="lightning_average_distance", + suggested_display_precision=2, + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, + ), + WeatherFlowSensorEntityDescription( + key="lightning_strike_count", + translation_key="lightning_count", + icon="mdi:lightning-bolt", + state_class=SensorStateClass.TOTAL, + raw_data_conv_fn=lambda raw_data: raw_data, + ), + WeatherFlowSensorEntityDescription( + key="precipitation_type", + translation_key="precipitation_type", + device_class=SensorDeviceClass.ENUM, + options=["none", "rain", "hail", "rain_hail", "unknown"], + icon="mdi:weather-rainy", + raw_data_conv_fn=precipitation_raw_conversion_fn, + ), + WeatherFlowSensorEntityDescription( + key="rain_accumulation_previous_minute", + icon="mdi:weather-rainy", + native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, + state_class=SensorStateClass.TOTAL, + device_class=SensorDeviceClass.PRECIPITATION, + imperial_suggested_unit=UnitOfPrecipitationDepth.INCHES, + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, + ), + WeatherFlowSensorEntityDescription( + key="rain_rate", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.PRECIPITATION_INTENSITY, + icon="mdi:weather-rainy", + native_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, + ), + WeatherFlowSensorEntityDescription( + key="relative_humidity", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, + ), + WeatherFlowSensorEntityDescription( + key="rssi", + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + event_subscriptions=[EVENT_STATUS_UPDATE], + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, + ), + WeatherFlowSensorEntityDescription( + key="station_pressure", + translation_key="station_pressure", + native_unit_of_measurement=UnitOfPressure.MBAR, + device_class=SensorDeviceClass.PRESSURE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=5, + imperial_suggested_unit=UnitOfPressure.INHG, + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, + ), + WeatherFlowSensorEntityDescription( + key="solar_radiation", + native_unit_of_measurement=UnitOfIrradiance.WATTS_PER_SQUARE_METER, + device_class=SensorDeviceClass.IRRADIANCE, + state_class=SensorStateClass.MEASUREMENT, + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, + ), + WeatherFlowSensorEntityDescription( + key="up_since", + translation_key="uptime", + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + event_subscriptions=[EVENT_STATUS_UPDATE], + raw_data_conv_fn=lambda raw_data: raw_data, + ), + WeatherFlowSensorEntityDescription( + key="uv", + translation_key="uv_index", + native_unit_of_measurement=UV_INDEX, + state_class=SensorStateClass.MEASUREMENT, + raw_data_conv_fn=lambda raw_data: raw_data, + ), + WeatherFlowSensorEntityDescription( + key="vapor_pressure", + translation_key="vapor_pressure", + native_unit_of_measurement=UnitOfPressure.MBAR, + device_class=SensorDeviceClass.PRESSURE, + state_class=SensorStateClass.MEASUREMENT, + imperial_suggested_unit=UnitOfPressure.INHG, + suggested_display_precision=5, + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, + ), + ## Wind Sensors + WeatherFlowSensorEntityDescription( + key="wind_gust", + translation_key="wind_gust", + icon="mdi:weather-windy", + device_class=SensorDeviceClass.WIND_SPEED, + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, + ), + WeatherFlowSensorEntityDescription( + key="wind_lull", + translation_key="wind_lull", + icon="mdi:weather-windy", + device_class=SensorDeviceClass.WIND_SPEED, + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, + ), + WeatherFlowSensorEntityDescription( + key="wind_speed", + device_class=SensorDeviceClass.WIND_SPEED, + icon="mdi:weather-windy", + event_subscriptions=[EVENT_RAPID_WIND, EVENT_OBSERVATION], + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, + ), + WeatherFlowSensorEntityDescription( + key="wind_speed_average", + translation_key="wind_speed_average", + icon="mdi:weather-windy", + device_class=SensorDeviceClass.WIND_SPEED, + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, + ), + WeatherFlowSensorEntityDescription( + key="wind_direction", + translation_key="wind_direction", + icon="mdi:compass-outline", + native_unit_of_measurement=DEGREE, + state_class=SensorStateClass.MEASUREMENT, + event_subscriptions=[EVENT_RAPID_WIND, EVENT_OBSERVATION], + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, + ), + WeatherFlowSensorEntityDescription( + key="wind_direction_average", + translation_key="wind_direction_average", + icon="mdi:compass-outline", + native_unit_of_measurement=DEGREE, + state_class=SensorStateClass.MEASUREMENT, + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up WeatherFlow sensors using config entry.""" + + @callback + def async_add_sensor(device: WeatherFlowDevice) -> None: + """Add WeatherFlow sensor.""" + LOGGER.debug("Adding sensors for %s", device) + + sensors: list[WeatherFlowSensorEntity] = [ + WeatherFlowSensorEntity( + device=device, + description=description, + is_metric=(hass.config.units == METRIC_SYSTEM), + ) + for description in SENSORS + if hasattr(device, description.key) + ] + + async_add_entities(sensors) + + config_entry.async_on_unload( + async_dispatcher_connect( + hass, + format_dispatch_call(config_entry), + async_add_sensor, + ) + ) + + +class WeatherFlowSensorEntity(SensorEntity): + """Defines a WeatherFlow sensor entity.""" + + entity_description: WeatherFlowSensorEntityDescription + _attr_should_poll = False + _attr_has_entity_name = True + + def __init__( + self, + device: WeatherFlowDevice, + description: WeatherFlowSensorEntityDescription, + is_metric: bool = True, + ) -> None: + """Initialize a WeatherFlow sensor entity.""" + self.device = device + self.entity_description = description + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device.serial_number)}, + manufacturer="WeatherFlow", + model=device.model, + name=device.serial_number, + sw_version=device.firmware_revision, + ) + + self._attr_unique_id = f"{device.serial_number}_{description.key}" + + # In the case of the USA - we may want to have a suggested US unit which differs from the internal suggested units + if description.imperial_suggested_unit is not None and not is_metric: + self._attr_suggested_unit_of_measurement = ( + description.imperial_suggested_unit + ) + + @property + def last_reset(self) -> datetime | None: + """Return the time when the sensor was last reset, if any.""" + if self.entity_description.state_class == SensorStateClass.TOTAL: + return self.device.last_report + return None + + @property + def native_value(self) -> datetime | StateType: + """Return the state of the sensor.""" + return self.entity_description.get_native_value(self.device) + + async def async_added_to_hass(self) -> None: + """Subscribe to events.""" + for event in self.entity_description.event_subscriptions: + self.async_on_remove( + self.device.on(event, lambda _: self.async_write_ha_state()) + ) diff --git a/homeassistant/components/weatherflow/strings.json b/homeassistant/components/weatherflow/strings.json new file mode 100644 index 00000000000..8f7a98abe04 --- /dev/null +++ b/homeassistant/components/weatherflow/strings.json @@ -0,0 +1,82 @@ +{ + "config": { + "step": { + "user": { + "title": "WeatherFlow discovery", + "description": "Unable to discover Tempest WeatherFlow devices. Click submit to try again.", + "data": { + "host": "[%key:common::config_flow::data::host%]" + } + } + }, + "error": { + "address_in_use": "Unable to open local UDP port 50222.", + "cannot_connect": "UDP discovery error." + }, + "abort": { + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" + } + }, + "entity": { + "sensor": { + "air_density": { + "name": "Air density" + }, + "dew_point": { + "name": "Dew point" + }, + "battery_voltage": { + "name": "Battery voltage" + }, + "feels_like": { + "name": "Feels like" + }, + "lightning_average_distance": { + "name": "Lightning average distance" + }, + "lightning_count": { + "name": "Lightning count" + }, + "precipitation_type": { + "name": "Precipitation type", + "state": { + "none": "None", + "rain": "Rain", + "hail": "Hail", + "rain_hail": "Rain and hail" + } + }, + "station_pressure": { + "name": "Air pressure" + }, + "uptime": { + "name": "Uptime" + }, + "uv_index": { + "name": "UV index" + }, + "vapor_pressure": { + "name": "Vapor pressure" + }, + "wet_bulb_temperature": { + "name": "Wet bulb temperature" + }, + "wind_speed_average": { + "name": "Wind speed average" + }, + "wind_direction": { + "name": "Wind direction" + }, + "wind_direction_average": { + "name": "Wind direction average" + }, + "wind_gust": { + "name": "Wind gust" + }, + "wind_lull": { + "name": "Wind lull" + } + } + } +} diff --git a/homeassistant/components/weatherkit/__init__.py b/homeassistant/components/weatherkit/__init__.py new file mode 100644 index 00000000000..fb41ffc1084 --- /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 00000000000..5762c4ae9b2 --- /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 00000000000..590ca65c9a9 --- /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 00000000000..a918ce0f850 --- /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 00000000000..d28a6ff3315 --- /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.4"] +} diff --git a/homeassistant/components/weatherkit/strings.json b/homeassistant/components/weatherkit/strings.json new file mode 100644 index 00000000000..4581028f209 --- /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 00000000000..ce997fa500f --- /dev/null +++ b/homeassistant/components/weatherkit/weather.py @@ -0,0 +1,263 @@ +"""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 + _attr_native_precipitation_unit = UnitOfLength.MILLIMETERS + + 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 ea21b7b5eba..cef9e7bb706 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -11,7 +11,7 @@ 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, @@ -52,7 +52,6 @@ from homeassistant.util.json import format_unserializable_data 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" @@ -271,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: @@ -282,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", @@ -359,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 ] @@ -372,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", @@ -596,47 +595,35 @@ async def handle_render_template( 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) - } + 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) - 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 - - connection.send_result(msg["id"], sources) + connection.send_result(msg["id"], _serialize_entity_sources(entity_sources)) @decorators.websocket_command( diff --git a/homeassistant/components/websocket_api/messages.py b/homeassistant/components/websocket_api/messages.py index e5fd5626302..6e88c36c328 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 9377fcefd92..5857ead2c11 100644 --- a/homeassistant/components/websocket_api/sensor.py +++ b/homeassistant/components/websocket_api/sensor.py @@ -34,7 +34,7 @@ class APICount(SensorEntity): 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 11ef186ba15..3a35ec1ed29 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 @@ class WiffiEntity(Entity): 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 @@ class WiffiEntity(Entity): ) ) - @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 @@ class WiffiEntity(Entity): 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 d0647b25297..cb1e1da41d8 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 @@ class BoolEntity(WiffiEntity, BinarySensorEntity): 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 1036ac7986f..e460a346bd7 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 @@ class NumberEntity(WiffiEntity, SensorEntity): 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 @@ class NumberEntity(WiffiEntity, SensorEntity): 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 @@ class StringEntity(WiffiEntity, SensorEntity): 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 101162302ae..334d750b1e1 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 @@ class WiLightValveSwitch(WiLightDevice, SwitchEntity): 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 @@ class WiLightValvePauseSwitch(WiLightDevice, SwitchEntity): 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 682efde8881..246bcc134d0 100644 --- a/homeassistant/components/withings/__init__.py +++ b/homeassistant/components/withings/__init__.py @@ -4,49 +4,59 @@ For more details about this platform, please refer to the documentation at """ from __future__ import annotations -import asyncio +from collections.abc import Awaitable, Callable from typing import Any +from aiohttp.hdrs import METH_HEAD, METH_POST from aiohttp.web import Request, Response import voluptuous as vol from withings_api.common import NotifyAppli -from homeassistant.components import webhook +from homeassistant.components import cloud from homeassistant.components.application_credentials import ( ClientCredential, async_import_client_credential, ) +from homeassistant.components.http import HomeAssistantView from homeassistant.components.webhook import ( - async_unregister as async_unregister_webhook, + async_generate_id as webhook_generate_id, + async_generate_url as webhook_generate_url, + async_register as webhook_register, + async_unregister as webhook_unregister, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_CLIENT_ID, CONF_CLIENT_SECRET, + CONF_TOKEN, CONF_WEBHOOK_ID, + EVENT_HOMEASSISTANT_STOP, Platform, ) -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_validation as cv +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv from homeassistant.helpers.event import async_call_later +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue +from homeassistant.helpers.start import async_at_started from homeassistant.helpers.typing import ConfigType -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 .api import ConfigEntryWithingsApi +from .const import ( + CONF_CLOUDHOOK_URL, + CONF_PROFILES, + CONF_USE_WEBHOOK, + DEFAULT_TITLE, + DOMAIN, + LOGGER, ) +from .coordinator import WithingsDataUpdateCoordinator -DOMAIN = const.DOMAIN PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.All( - cv.deprecated(const.CONF_PROFILES), + cv.deprecated(CONF_PROFILES), cv.deprecated(CONF_CLIENT_ID), cv.deprecated(CONF_CLIENT_SECRET), vol.Schema( @@ -55,8 +65,8 @@ CONFIG_SCHEMA = vol.Schema( vol.Optional(CONF_CLIENT_SECRET): vol.All( cv.string, vol.Length(min=1) ), - vol.Optional(const.CONF_USE_WEBHOOK, default=False): cv.boolean, - vol.Optional(const.CONF_PROFILES): vol.All( + vol.Optional(CONF_USE_WEBHOOK): cv.boolean, + vol.Optional(CONF_PROFILES): vol.All( cv.ensure_list, vol.Unique(), vol.Length(min=1), @@ -72,147 +82,198 @@ CONFIG_SCHEMA = vol.Schema( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Withings component.""" - if not (conf := config.get(DOMAIN)): - # Apply the defaults. - conf = CONFIG_SCHEMA({DOMAIN: {}})[DOMAIN] - hass.data[DOMAIN] = {const.CONFIG: conf} - return True - hass.data[DOMAIN] = {const.CONFIG: conf} - - # Setup the oauth2 config flow. - if CONF_CLIENT_ID in conf: - await async_import_client_credential( + if conf := config.get(DOMAIN): + async_create_issue( hass, - DOMAIN, - ClientCredential( - conf[CONF_CLIENT_ID], - conf[CONF_CLIENT_SECRET], - ), - ) - _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 " - "the UI automatically and can be safely removed from your " - "configuration.yaml file" + 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": "Withings", + }, ) + if CONF_CLIENT_ID in conf: + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential( + conf[CONF_CLIENT_ID], + conf[CONF_CLIENT_SECRET], + ), + ) return True async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Withings from a config entry.""" - config_updates: dict[str, Any] = {} + if CONF_WEBHOOK_ID not in entry.data or entry.unique_id is None: + new_data = entry.data.copy() + unique_id = str(entry.data[CONF_TOKEN]["userid"]) + if CONF_WEBHOOK_ID not in new_data: + new_data[CONF_WEBHOOK_ID] = webhook_generate_id() - # 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"]) + hass.config_entries.async_update_entry( + entry, data=new_data, unique_id=unique_id + ) - # 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 config_updates: - hass.config_entries.async_update_entry(entry, **config_updates) - - data_manager = await async_get_data_manager(hass, entry) - - _LOGGER.debug("Confirming %s is authenticated to withings", data_manager.profile) - await data_manager.poll_data_update_coordinator.async_config_entry_first_refresh() - - webhook.async_register( - hass, - const.DOMAIN, - "Withings notify", - data_manager.webhook_config.id, - async_webhook_handler, + client = ConfigEntryWithingsApi( + hass=hass, + config_entry=entry, + implementation=await config_entry_oauth2_flow.async_get_config_entry_implementation( + hass, entry + ), ) + coordinator = WithingsDataUpdateCoordinator(hass, client) - # Perform first webhook subscription check. - if data_manager.webhook_config.enabled: - data_manager.async_start_polling_webhook_subscriptions() + await coordinator.async_config_entry_first_refresh() - @callback - def async_call_later_callback(now) -> None: - hass.async_create_task( - data_manager.subscription_update_coordinator.async_refresh() + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + + async def unregister_webhook( + _: Any, + ) -> None: + LOGGER.debug("Unregister Withings webhook (%s)", entry.data[CONF_WEBHOOK_ID]) + webhook_unregister(hass, entry.data[CONF_WEBHOOK_ID]) + await hass.data[DOMAIN][entry.entry_id].async_unsubscribe_webhooks() + + async def register_webhook( + _: Any, + ) -> None: + if cloud.async_active_subscription(hass): + webhook_url = await async_cloudhook_generate_url(hass, entry) + else: + webhook_url = webhook_generate_url(hass, entry.data[CONF_WEBHOOK_ID]) + + if not webhook_url.startswith("https://"): + LOGGER.warning( + "Webhook not registered - " + "https and port 443 is required to register the webhook" ) + return - # Start subscription check in the background, outside this component's setup. - entry.async_on_unload(async_call_later(hass, 1, async_call_later_callback)) + webhook_name = "Withings" + if entry.title != DEFAULT_TITLE: + webhook_name = " ".join([DEFAULT_TITLE, entry.title]) + + webhook_register( + hass, + DOMAIN, + webhook_name, + entry.data[CONF_WEBHOOK_ID], + get_webhook_handler(coordinator), + ) + + await hass.data[DOMAIN][entry.entry_id].async_subscribe_webhooks(webhook_url) + LOGGER.debug("Register Withings webhook: %s", webhook_url) + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, unregister_webhook) + ) + + async def manage_cloudhook(state: cloud.CloudConnectionState) -> None: + if state is cloud.CloudConnectionState.CLOUD_CONNECTED: + await register_webhook(None) + + if state is cloud.CloudConnectionState.CLOUD_DISCONNECTED: + await unregister_webhook(None) + async_call_later(hass, 30, register_webhook) + + if cloud.async_active_subscription(hass): + if cloud.async_is_connected(hass): + await register_webhook(None) + cloud.async_listen_connection_change(hass, manage_cloudhook) + else: + async_at_started(hass, register_webhook) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(update_listener)) return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload Withings config entry.""" - data_manager = await async_get_data_manager(hass, entry) - data_manager.async_stop_polling_webhook_subscriptions() + webhook_unregister(hass, entry.data[CONF_WEBHOOK_ID]) - async_unregister_webhook(hass, data_manager.webhook_config.id) - - await asyncio.gather( - data_manager.async_unsubscribe_webhook(), - hass.config_entries.async_unload_platforms(entry, PLATFORMS), - ) - - async_remove_data_manager(hass, entry) - - return True + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + return unload_ok -async def async_webhook_handler( - hass: HomeAssistant, webhook_id: str, request: Request -) -> Response | None: - """Handle webhooks calls.""" - # Handle http head calls to the path. - # When creating a notify subscription, Withings will check that the endpoint is running by sending a HEAD request. - if request.method.upper() == "HEAD": - return Response() +async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle options update.""" + await hass.config_entries.async_reload(entry.entry_id) - if request.method.upper() != "POST": - return json_message_response("Invalid method", message_code=2) - # Handle http post calls to the path. - if not request.body_exists: - return json_message_response("No request body", message_code=12) - - params = await request.post() - - if "appli" not in params: - return json_message_response("Parameter appli not provided", message_code=20) - - try: - appli = NotifyAppli(int(params.getone("appli"))) # type: ignore[arg-type] - except ValueError: - return json_message_response("Invalid appli provided", message_code=21) - - data_manager = get_data_manager_by_webhook_id(hass, webhook_id) - if not data_manager: - _LOGGER.error( - ( - "Webhook id %s not handled by data manager. This is a bug and should be" - " reported" - ), - webhook_id, +async def async_cloudhook_generate_url(hass: HomeAssistant, entry: ConfigEntry) -> str: + """Generate the full URL for a webhook_id.""" + if CONF_CLOUDHOOK_URL not in entry.data: + webhook_url = await cloud.async_create_cloudhook( + hass, entry.data[CONF_WEBHOOK_ID] ) - return json_message_response("User not found", message_code=1) + data = {**entry.data, CONF_CLOUDHOOK_URL: webhook_url} + hass.config_entries.async_update_entry(entry, data=data) + return webhook_url + return str(entry.data[CONF_CLOUDHOOK_URL]) - # Run this in the background and return immediately. - hass.async_create_task(data_manager.async_webhook_data_updated(appli)) - return json_message_response("Success", message_code=0) +async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Cleanup when entry is removed.""" + if cloud.async_active_subscription(hass): + try: + LOGGER.debug( + "Removing Withings cloudhook (%s)", entry.data[CONF_WEBHOOK_ID] + ) + await cloud.async_delete_cloudhook(hass, entry.data[CONF_WEBHOOK_ID]) + except cloud.CloudNotAvailable: + pass + + +def json_message_response(message: str, message_code: int) -> Response: + """Produce common json output.""" + return HomeAssistantView.json({"message": message, "code": message_code}) + + +def get_webhook_handler( + coordinator: WithingsDataUpdateCoordinator, +) -> Callable[[HomeAssistant, str, Request], Awaitable[Response | None]]: + """Return webhook handler.""" + + async def async_webhook_handler( + hass: HomeAssistant, webhook_id: str, request: Request + ) -> Response | None: + # Handle http head calls to the path. + # When creating a notify subscription, Withings will check that the endpoint is running by sending a HEAD request. + if request.method == METH_HEAD: + return Response() + + if request.method != METH_POST: + return json_message_response("Invalid method", message_code=2) + + # Handle http post calls to the path. + if not request.body_exists: + return json_message_response("No request body", message_code=12) + + params = await request.post() + + if "appli" not in params: + return json_message_response( + "Parameter appli not provided", message_code=20 + ) + + try: + appli = NotifyAppli(int(params.getone("appli"))) # type: ignore[arg-type] + except ValueError: + return json_message_response("Invalid appli provided", message_code=21) + + await coordinator.async_webhook_data_updated(appli) + + return json_message_response("Success", message_code=0) + + return async_webhook_handler diff --git a/homeassistant/components/withings/api.py b/homeassistant/components/withings/api.py new file mode 100644 index 00000000000..f9739d3fb6f --- /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 e5c401d5e74..1d5b52466c4 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 6b072030bda..309ef45623f 100644 --- a/homeassistant/components/withings/binary_sensor.py +++ b/homeassistant/components/withings/binary_sensor.py @@ -14,13 +14,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .common import ( - BaseWithingsSensor, - UpdateType, - WithingsEntityDescription, - async_get_data_manager, -) -from .const import Measurement +from .const import DOMAIN, Measurement +from .coordinator import WithingsDataUpdateCoordinator +from .entity import WithingsEntity, WithingsEntityDescription @dataclass @@ -36,9 +32,8 @@ BINARY_SENSORS = [ 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, ), ] @@ -50,17 +45,16 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the sensor config entry.""" - data_manager = await async_get_data_manager(hass, entry) + coordinator: WithingsDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] entities = [ - WithingsHealthBinarySensor(data_manager, attribute) - for attribute in BINARY_SENSORS + WithingsBinarySensor(coordinator, attribute) for attribute in BINARY_SENSORS ] - async_add_entities(entities, True) + async_add_entities(entities) -class WithingsHealthBinarySensor(BaseWithingsSensor, BinarySensorEntity): +class WithingsBinarySensor(WithingsEntity, BinarySensorEntity): """Implementation of a Withings sensor.""" entity_description: WithingsBinarySensorEntityDescription @@ -68,4 +62,4 @@ class WithingsHealthBinarySensor(BaseWithingsSensor, BinarySensorEntity): @property def is_on(self) -> bool | None: """Return true if the binary sensor is on.""" - return self._state_data + return self.coordinator.in_bed diff --git a/homeassistant/components/withings/common.py b/homeassistant/components/withings/common.py deleted file mode 100644 index 17e3c551bcc..00000000000 --- a/homeassistant/components/withings/common.py +++ /dev/null @@ -1,729 +0,0 @@ -"""Common code for Withings.""" -from __future__ import annotations - -import asyncio -from collections.abc import Callable -from dataclasses import dataclass -import datetime -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, - MeasureGroupAttribs, - 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 - -_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" - - -class UpdateType(StrEnum): - """Data update type.""" - - POLL = "poll" - 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.""" - - id: str - url: str - enabled: bool - - -WITHINGS_MEASURE_TYPE_MAP: dict[ - NotifyAppli | GetSleepSummaryField | MeasureType, Measurement -] = { - MeasureType.WEIGHT: Measurement.WEIGHT_KG, - MeasureType.FAT_MASS_WEIGHT: Measurement.FAT_MASS_KG, - MeasureType.FAT_FREE_MASS: Measurement.FAT_FREE_MASS_KG, - MeasureType.MUSCLE_MASS: Measurement.MUSCLE_MASS_KG, - MeasureType.BONE_MASS: Measurement.BONE_MASS_KG, - MeasureType.HEIGHT: Measurement.HEIGHT_M, - MeasureType.TEMPERATURE: Measurement.TEMP_C, - MeasureType.BODY_TEMPERATURE: Measurement.BODY_TEMP_C, - MeasureType.SKIN_TEMPERATURE: Measurement.SKIN_TEMP_C, - MeasureType.FAT_RATIO: Measurement.FAT_RATIO_PCT, - MeasureType.DIASTOLIC_BLOOD_PRESSURE: Measurement.DIASTOLIC_MMHG, - MeasureType.SYSTOLIC_BLOOD_PRESSURE: Measurement.SYSTOLIC_MMGH, - MeasureType.HEART_RATE: Measurement.HEART_PULSE_BPM, - MeasureType.SP02: Measurement.SPO2_PCT, - MeasureType.HYDRATION: Measurement.HYDRATION, - MeasureType.PULSE_WAVE_VELOCITY: Measurement.PWV, - GetSleepSummaryField.BREATHING_DISTURBANCES_INTENSITY: ( - Measurement.SLEEP_BREATHING_DISTURBANCES_INTENSITY - ), - GetSleepSummaryField.DEEP_SLEEP_DURATION: Measurement.SLEEP_DEEP_DURATION_SECONDS, - GetSleepSummaryField.DURATION_TO_SLEEP: Measurement.SLEEP_TOSLEEP_DURATION_SECONDS, - GetSleepSummaryField.DURATION_TO_WAKEUP: ( - Measurement.SLEEP_TOWAKEUP_DURATION_SECONDS - ), - GetSleepSummaryField.HR_AVERAGE: Measurement.SLEEP_HEART_RATE_AVERAGE, - GetSleepSummaryField.HR_MAX: Measurement.SLEEP_HEART_RATE_MAX, - GetSleepSummaryField.HR_MIN: Measurement.SLEEP_HEART_RATE_MIN, - GetSleepSummaryField.LIGHT_SLEEP_DURATION: Measurement.SLEEP_LIGHT_DURATION_SECONDS, - GetSleepSummaryField.REM_SLEEP_DURATION: Measurement.SLEEP_REM_DURATION_SECONDS, - GetSleepSummaryField.RR_AVERAGE: Measurement.SLEEP_RESPIRATORY_RATE_AVERAGE, - GetSleepSummaryField.RR_MAX: Measurement.SLEEP_RESPIRATORY_RATE_MAX, - GetSleepSummaryField.RR_MIN: Measurement.SLEEP_RESPIRATORY_RATE_MIN, - GetSleepSummaryField.SLEEP_SCORE: Measurement.SLEEP_SCORE, - GetSleepSummaryField.SNORING: Measurement.SLEEP_SNORING, - GetSleepSummaryField.SNORING_EPISODE_COUNT: Measurement.SLEEP_SNORING_EPISODE_COUNT, - GetSleepSummaryField.WAKEUP_COUNT: Measurement.SLEEP_WAKEUP_COUNT, - GetSleepSummaryField.WAKEUP_DURATION: Measurement.SLEEP_WAKEUP_DURATION_SECONDS, - NotifyAppli.BED_IN: Measurement.IN_BED, -} - - -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}) - - -class WebhookAvailability(IntEnum): - """Represents various statuses of webhook availability.""" - - SUCCESS = 0 - CONNECT_ERROR = 1 - HTTP_ERROR = 2 - NOT_WEBHOOK = 3 - - -class WebhookUpdateCoordinator: - """Coordinates webhook data updates across listeners.""" - - def __init__(self, hass: HomeAssistant, user_id: int) -> None: - """Initialize the object.""" - self._hass = hass - self._user_id = user_id - self._listeners: list[CALLBACK_TYPE] = [] - self.data: dict[Measurement, Any] = {} - - def async_add_listener(self, listener: CALLBACK_TYPE) -> Callable[[], None]: - """Add a listener.""" - self._listeners.append(listener) - - @callback - def remove_listener() -> None: - self.async_remove_listener(listener) - - return remove_listener - - def async_remove_listener(self, listener: CALLBACK_TYPE) -> None: - """Remove a listener.""" - self._listeners.remove(listener) - - def update_data(self, measurement: Measurement, value: Any) -> None: - """Update the data object and notify listeners the data has changed.""" - self.data[measurement] = value - self.notify_data_changed() - - def notify_data_changed(self) -> None: - """Notify all listeners the data has changed.""" - for listener in self._listeners: - listener() - - -class DataManager: - """Manage withing data.""" - - def __init__( - self, - hass: HomeAssistant, - profile: str, - api: ConfigEntryWithingsApi, - user_id: int, - webhook_config: WebhookConfig, - ) -> None: - """Initialize the data manager.""" - 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._is_available = True - self._cancel_interval_update_interval: CALLBACK_TYPE | None = None - self._cancel_configure_webhook_subscribe_interval: CALLBACK_TYPE | None = None - self._api_notification_id = f"withings_{self._user_id}" - - self.subscription_update_coordinator = DataUpdateCoordinator( - hass, - _LOGGER, - name="subscription_update_coordinator", - update_interval=timedelta(minutes=120), - update_method=self.async_subscribe_webhook, - ) - self.poll_data_update_coordinator = DataUpdateCoordinator[ - dict[MeasureType, Any] | None - ]( - hass, - _LOGGER, - name="poll_data_update_coordinator", - update_interval=timedelta(minutes=120) - if self._webhook_config.enabled - else timedelta(minutes=10), - update_method=self.async_get_all_data, - ) - self.webhook_update_coordinator = WebhookUpdateCoordinator( - self._hass, self._user_id - ) - self._cancel_subscription_update: Callable[[], None] | None = None - self._subscribe_webhook_run_count = 0 - - @property - def webhook_config(self) -> WebhookConfig: - """Get the webhook config.""" - return self._webhook_config - - @property - 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() - - def empty_listener() -> None: - pass - - self._cancel_subscription_update = ( - self.subscription_update_coordinator.async_add_listener(empty_listener) - ) - - def async_stop_polling_webhook_subscriptions(self) -> None: - """Stop polling webhook subscriptions.""" - if self._cancel_subscription_update: - 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") - - # 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") - 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) - - subscribed_applis = frozenset( - profile.appli - for profile in response.profiles - if profile.callbackurl == self._webhook_config.url - ) - - # Determine what subscriptions need to be created. - ignored_applis = frozenset({NotifyAppli.USER, NotifyAppli.UNKNOWN}) - to_add_applis = frozenset( - appli - for appli in NotifyAppli - if appli not in subscribed_applis and appli not in ignored_applis - ) - - # Subscribe to each one. - for appli in to_add_applis: - _LOGGER.debug( - "Subscribing %s for %s in %s seconds", - self._webhook_config.url, - appli, - self._notify_subscribe_delay.total_seconds(), - ) - # 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 - ) - - 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) - - # Revoke subscriptions. - for profile in response.profiles: - _LOGGER.debug( - "Unsubscribing %s for %s in %s seconds", - profile.callbackurl, - profile.appli, - self._notify_unsubscribe_delay.total_seconds(), - ) - # 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 - ) - - 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) - except Exception as exception: - # User is not authenticated. - if isinstance( - exception, (UnauthorizedException, AuthFailedException) - ) or NOT_AUTHENTICATED_ERROR.match(str(exception)): - self._api.config_entry.async_start_reauth(self._hass) - return 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") - 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 - ) - - # Sort from oldest to newest. - groups = sorted( - query_measure_groups( - response, MeasureTypes.ANY, MeasureGroupAttribs.UNAMBIGUOUS - ), - key=lambda group: group.created.datetime, - reverse=False, - ) - - return { - WITHINGS_MEASURE_TYPE_MAP[measure.type]: round( - float(measure.value * pow(10, measure.unit)), 2 - ) - for group in groups - for measure in group.measures - if measure.type in WITHINGS_MEASURE_TYPE_MAP - } - - async def async_get_sleep_summary(self) -> dict[Measurement, Any]: - """Get the sleep summary data.""" - _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( - hours=12 - ) - 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) - - # Set the default to empty lists. - raw_values: dict[GetSleepSummaryField, list[int]] = { - field: [] for field in GetSleepSummaryField - } - - # Collect the raw data. - for serie in response.series: - data = serie.data - - for field in GetSleepSummaryField: - raw_values[field].append(dict(data)[field.value]) - - values: dict[GetSleepSummaryField, float] = {} - - def average(data: list[int]) -> float: - return sum(data) / len(data) - - def set_value(field: GetSleepSummaryField, func: Callable) -> None: - non_nones = [ - value for value in raw_values.get(field, []) if value is not None - ] - values[field] = func(non_nones) if non_nones else None - - set_value(GetSleepSummaryField.BREATHING_DISTURBANCES_INTENSITY, average) - set_value(GetSleepSummaryField.DEEP_SLEEP_DURATION, sum) - set_value(GetSleepSummaryField.DURATION_TO_SLEEP, average) - set_value(GetSleepSummaryField.DURATION_TO_WAKEUP, average) - set_value(GetSleepSummaryField.HR_AVERAGE, average) - set_value(GetSleepSummaryField.HR_MAX, average) - set_value(GetSleepSummaryField.HR_MIN, average) - set_value(GetSleepSummaryField.LIGHT_SLEEP_DURATION, sum) - set_value(GetSleepSummaryField.REM_SLEEP_DURATION, sum) - set_value(GetSleepSummaryField.RR_AVERAGE, average) - set_value(GetSleepSummaryField.RR_MAX, average) - set_value(GetSleepSummaryField.RR_MIN, average) - set_value(GetSleepSummaryField.SLEEP_SCORE, max) - set_value(GetSleepSummaryField.SNORING, average) - set_value(GetSleepSummaryField.SNORING_EPISODE_COUNT, sum) - set_value(GetSleepSummaryField.WAKEUP_COUNT, sum) - set_value(GetSleepSummaryField.WAKEUP_DURATION, average) - - return { - WITHINGS_MEASURE_TYPE_MAP[field]: round(value, 4) - if value is not None - else None - for field, value in values.items() - } - - 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") - if data_category in { - NotifyAppli.WEIGHT, - NotifyAppli.CIRCULATORY, - NotifyAppli.SLEEP, - }: - await self.poll_data_update_coordinator.async_request_refresh() - - elif data_category in {NotifyAppli.BED_IN, NotifyAppli.BED_OUT}: - self.webhook_update_coordinator.update_data( - Measurement.IN_BED, data_category == NotifyAppli.BED_IN - ) - - -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: - """Get the data manager for a config entry.""" - hass.data.setdefault(const.DOMAIN, {}) - hass.data[const.DOMAIN].setdefault(config_entry.entry_id, {}) - 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) - config_entry_data[const.DATA_MANAGER] = DataManager( - hass, - profile, - ConfigEntryWithingsApi( - hass=hass, - config_entry=config_entry, - implementation=await config_entry_oauth2_flow.async_get_config_entry_implementation( - hass, config_entry - ), - ), - config_entry.data["token"]["userid"], - WebhookConfig( - id=config_entry.data[CONF_WEBHOOK_ID], - url=webhook.async_generate_url( - hass, config_entry.data[CONF_WEBHOOK_ID] - ), - enabled=config_entry.data[const.CONF_USE_WEBHOOK], - ), - ) - - return config_entry_data[const.DATA_MANAGER] - - -def get_data_manager_by_webhook_id( - hass: HomeAssistant, webhook_id: str -) -> DataManager | None: - """Get a data manager by it's webhook id.""" - return next( - iter( - [ - data_manager - for data_manager in get_all_data_managers(hass) - if data_manager.webhook_config.id == webhook_id - ] - ), - None, - ) - - -def get_all_data_managers(hass: HomeAssistant) -> tuple[DataManager, ...]: - """Get all configured data managers.""" - return tuple( - config_entry_data[const.DATA_MANAGER] - for config_entry_data in hass.data[const.DOMAIN].values() - if const.DATA_MANAGER in config_entry_data - ) - - -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 b0fa1876d92..35a4582ae4d 100644 --- a/homeassistant/components/withings/config_flow.py +++ b/homeassistant/components/withings/config_flow.py @@ -5,26 +5,25 @@ from collections.abc import Mapping import logging from typing import Any -import voluptuous as vol from withings_api.common import AuthScope +from homeassistant.components.webhook import async_generate_id +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_TOKEN, CONF_WEBHOOK_ID 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 @property def logger(self) -> logging.Logger: @@ -45,64 +44,38 @@ class WithingsFlowHandler( ) } - 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() - self.context.update({"title_placeholders": placeholders}) + return self.async_create_entry( + title=DEFAULT_TITLE, + data={**data, CONF_WEBHOOK_ID: async_generate_id()}, + options={CONF_USE_WEBHOOK: False}, + ) - return self.async_show_form( - step_id="reauth_confirm", - description_placeholders=placeholders, - ) + if self.reauth_entry.unique_id == user_id: + self.hass.config_entries.async_update_entry( + self.reauth_entry, data={**self.reauth_entry.data, **data} + ) + return self.async_abort(reason="reauth_successful") - 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) - - return self.async_create_entry(title=data[const.PROFILE], data=data) + return self.async_abort(reason="wrong_account") diff --git a/homeassistant/components/withings/const.py b/homeassistant/components/withings/const.py index 02d8977c604..6129e0c4b29 100644 --- a/homeassistant/components/withings/const.py +++ b/homeassistant/components/withings/const.py @@ -1,8 +1,11 @@ """Constants used by the Withings component.""" from enum import StrEnum +import logging +DEFAULT_TITLE = "Withings" CONF_PROFILES = "profiles" CONF_USE_WEBHOOK = "use_webhook" +CONF_CLOUDHOOK_URL = "cloudhook_url" DATA_MANAGER = "data_manager" @@ -12,6 +15,8 @@ LOG_NAMESPACE = "homeassistant.components.withings" 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/coordinator.py b/homeassistant/components/withings/coordinator.py new file mode 100644 index 00000000000..128d4e39193 --- /dev/null +++ b/homeassistant/components/withings/coordinator.py @@ -0,0 +1,266 @@ +"""Withings coordinator.""" +import asyncio +from collections.abc import Callable +from datetime import timedelta +from typing import Any + +from withings_api.common import ( + AuthFailedException, + GetSleepSummaryField, + MeasureGroupAttribs, + MeasureType, + MeasureTypes, + NotifyAppli, + UnauthorizedException, + query_measure_groups, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.util import dt as dt_util + +from .api import ConfigEntryWithingsApi +from .const import LOGGER, Measurement + +SUBSCRIBE_DELAY = timedelta(seconds=5) +UNSUBSCRIBE_DELAY = timedelta(seconds=1) + +WITHINGS_MEASURE_TYPE_MAP: dict[ + NotifyAppli | GetSleepSummaryField | MeasureType, Measurement +] = { + MeasureType.WEIGHT: Measurement.WEIGHT_KG, + MeasureType.FAT_MASS_WEIGHT: Measurement.FAT_MASS_KG, + MeasureType.FAT_FREE_MASS: Measurement.FAT_FREE_MASS_KG, + MeasureType.MUSCLE_MASS: Measurement.MUSCLE_MASS_KG, + MeasureType.BONE_MASS: Measurement.BONE_MASS_KG, + MeasureType.HEIGHT: Measurement.HEIGHT_M, + MeasureType.TEMPERATURE: Measurement.TEMP_C, + MeasureType.BODY_TEMPERATURE: Measurement.BODY_TEMP_C, + MeasureType.SKIN_TEMPERATURE: Measurement.SKIN_TEMP_C, + MeasureType.FAT_RATIO: Measurement.FAT_RATIO_PCT, + MeasureType.DIASTOLIC_BLOOD_PRESSURE: Measurement.DIASTOLIC_MMHG, + MeasureType.SYSTOLIC_BLOOD_PRESSURE: Measurement.SYSTOLIC_MMGH, + MeasureType.HEART_RATE: Measurement.HEART_PULSE_BPM, + MeasureType.SP02: Measurement.SPO2_PCT, + MeasureType.HYDRATION: Measurement.HYDRATION, + MeasureType.PULSE_WAVE_VELOCITY: Measurement.PWV, + GetSleepSummaryField.BREATHING_DISTURBANCES_INTENSITY: ( + Measurement.SLEEP_BREATHING_DISTURBANCES_INTENSITY + ), + GetSleepSummaryField.DEEP_SLEEP_DURATION: Measurement.SLEEP_DEEP_DURATION_SECONDS, + GetSleepSummaryField.DURATION_TO_SLEEP: Measurement.SLEEP_TOSLEEP_DURATION_SECONDS, + GetSleepSummaryField.DURATION_TO_WAKEUP: ( + Measurement.SLEEP_TOWAKEUP_DURATION_SECONDS + ), + GetSleepSummaryField.HR_AVERAGE: Measurement.SLEEP_HEART_RATE_AVERAGE, + GetSleepSummaryField.HR_MAX: Measurement.SLEEP_HEART_RATE_MAX, + GetSleepSummaryField.HR_MIN: Measurement.SLEEP_HEART_RATE_MIN, + GetSleepSummaryField.LIGHT_SLEEP_DURATION: Measurement.SLEEP_LIGHT_DURATION_SECONDS, + GetSleepSummaryField.REM_SLEEP_DURATION: Measurement.SLEEP_REM_DURATION_SECONDS, + GetSleepSummaryField.RR_AVERAGE: Measurement.SLEEP_RESPIRATORY_RATE_AVERAGE, + GetSleepSummaryField.RR_MAX: Measurement.SLEEP_RESPIRATORY_RATE_MAX, + GetSleepSummaryField.RR_MIN: Measurement.SLEEP_RESPIRATORY_RATE_MIN, + GetSleepSummaryField.SLEEP_SCORE: Measurement.SLEEP_SCORE, + GetSleepSummaryField.SNORING: Measurement.SLEEP_SNORING, + GetSleepSummaryField.SNORING_EPISODE_COUNT: Measurement.SLEEP_SNORING_EPISODE_COUNT, + GetSleepSummaryField.WAKEUP_COUNT: Measurement.SLEEP_WAKEUP_COUNT, + GetSleepSummaryField.WAKEUP_DURATION: Measurement.SLEEP_WAKEUP_DURATION_SECONDS, + NotifyAppli.BED_IN: Measurement.IN_BED, +} + +UPDATE_INTERVAL = timedelta(minutes=10) + + +class WithingsDataUpdateCoordinator(DataUpdateCoordinator[dict[Measurement, Any]]): + """Base coordinator.""" + + in_bed: bool | None = None + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant, client: ConfigEntryWithingsApi) -> None: + """Initialize the Withings data coordinator.""" + super().__init__(hass, LOGGER, name="Withings", update_interval=UPDATE_INTERVAL) + self._client = client + + async def async_subscribe_webhooks(self, webhook_url: str) -> None: + """Subscribe to webhooks.""" + await self.async_unsubscribe_webhooks() + + current_webhooks = await self._client.async_notify_list() + + subscribed_notifications = frozenset( + profile.appli + for profile in current_webhooks.profiles + if profile.callbackurl == webhook_url + ) + + notification_to_subscribe = ( + set(NotifyAppli) + - subscribed_notifications + - {NotifyAppli.USER, NotifyAppli.UNKNOWN} + ) + + for notification in notification_to_subscribe: + LOGGER.debug( + "Subscribing %s for %s in %s seconds", + webhook_url, + notification, + SUBSCRIBE_DELAY.total_seconds(), + ) + # 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(SUBSCRIBE_DELAY.total_seconds()) + await self._client.async_notify_subscribe(webhook_url, notification) + self.update_interval = None + + async def async_unsubscribe_webhooks(self) -> None: + """Unsubscribe to webhooks.""" + current_webhooks = await self._client.async_notify_list() + + for webhook_configuration in current_webhooks.profiles: + LOGGER.debug( + "Unsubscribing %s for %s in %s seconds", + webhook_configuration.callbackurl, + webhook_configuration.appli, + UNSUBSCRIBE_DELAY.total_seconds(), + ) + # Quick calls to Withings can result in the service returning errors. + # Give them some time to cool down. + await asyncio.sleep(UNSUBSCRIBE_DELAY.total_seconds()) + await self._client.async_notify_revoke( + webhook_configuration.callbackurl, webhook_configuration.appli + ) + self.update_interval = UPDATE_INTERVAL + + async def _async_update_data(self) -> dict[Measurement, Any]: + try: + measurements = await self._get_measurements() + sleep_summary = await self._get_sleep_summary() + except (UnauthorizedException, AuthFailedException) as exc: + raise ConfigEntryAuthFailed from exc + return { + **measurements, + **sleep_summary, + } + + async def _get_measurements(self) -> dict[Measurement, Any]: + LOGGER.debug("Updating withings measures") + now = dt_util.utcnow() + startdate = now - timedelta(days=7) + + response = await self._client.async_measure_get_meas( + None, None, startdate, now, None, startdate + ) + + # Sort from oldest to newest. + groups = sorted( + query_measure_groups( + response, MeasureTypes.ANY, MeasureGroupAttribs.UNAMBIGUOUS + ), + key=lambda group: group.created.datetime, + reverse=False, + ) + + return { + WITHINGS_MEASURE_TYPE_MAP[measure.type]: round( + float(measure.value * pow(10, measure.unit)), 2 + ) + for group in groups + for measure in group.measures + if measure.type in WITHINGS_MEASURE_TYPE_MAP + } + + async def _get_sleep_summary(self) -> dict[Measurement, Any]: + now = dt_util.now() + yesterday = now - timedelta(days=1) + yesterday_noon = dt_util.start_of_local_day(yesterday) + timedelta(hours=12) + yesterday_noon_utc = dt_util.as_utc(yesterday_noon) + + response = await self._client.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]] = { + field: [] for field in GetSleepSummaryField + } + + # Collect the raw data. + for serie in response.series: + data = serie.data + + for field in GetSleepSummaryField: + raw_values[field].append(dict(data)[field.value]) + + values: dict[GetSleepSummaryField, float] = {} + + def average(data: list[int]) -> float: + return sum(data) / len(data) + + def set_value(field: GetSleepSummaryField, func: Callable) -> None: + non_nones = [ + value for value in raw_values.get(field, []) if value is not None + ] + values[field] = func(non_nones) if non_nones else None + + set_value(GetSleepSummaryField.BREATHING_DISTURBANCES_INTENSITY, average) + set_value(GetSleepSummaryField.DEEP_SLEEP_DURATION, sum) + set_value(GetSleepSummaryField.DURATION_TO_SLEEP, average) + set_value(GetSleepSummaryField.DURATION_TO_WAKEUP, average) + set_value(GetSleepSummaryField.HR_AVERAGE, average) + set_value(GetSleepSummaryField.HR_MAX, average) + set_value(GetSleepSummaryField.HR_MIN, average) + set_value(GetSleepSummaryField.LIGHT_SLEEP_DURATION, sum) + set_value(GetSleepSummaryField.REM_SLEEP_DURATION, sum) + set_value(GetSleepSummaryField.RR_AVERAGE, average) + set_value(GetSleepSummaryField.RR_MAX, average) + set_value(GetSleepSummaryField.RR_MIN, average) + set_value(GetSleepSummaryField.SLEEP_SCORE, max) + set_value(GetSleepSummaryField.SNORING, average) + set_value(GetSleepSummaryField.SNORING_EPISODE_COUNT, sum) + set_value(GetSleepSummaryField.WAKEUP_COUNT, sum) + set_value(GetSleepSummaryField.WAKEUP_DURATION, average) + + return { + WITHINGS_MEASURE_TYPE_MAP[field]: round(value, 4) + if value is not None + else None + for field, value in values.items() + } + + async def async_webhook_data_updated( + self, notification_category: NotifyAppli + ) -> None: + """Update data when webhook is called.""" + LOGGER.debug("Withings webhook triggered") + if notification_category in { + NotifyAppli.WEIGHT, + NotifyAppli.CIRCULATORY, + NotifyAppli.SLEEP, + }: + await self.async_request_refresh() + + elif notification_category in {NotifyAppli.BED_IN, NotifyAppli.BED_OUT}: + self.in_bed = notification_category == NotifyAppli.BED_IN + self.async_update_listeners() diff --git a/homeassistant/components/withings/entity.py b/homeassistant/components/withings/entity.py new file mode 100644 index 00000000000..8005f97bfaa --- /dev/null +++ b/homeassistant/components/withings/entity.py @@ -0,0 +1,47 @@ +"""Base entity for Withings.""" +from __future__ import annotations + +from dataclasses import dataclass + +from withings_api.common import GetSleepSummaryField, MeasureType, NotifyAppli + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, Measurement +from .coordinator import WithingsDataUpdateCoordinator + + +@dataclass +class WithingsEntityDescriptionMixin: + """Mixin for describing withings data.""" + + measurement: Measurement + measure_type: NotifyAppli | GetSleepSummaryField | MeasureType + + +@dataclass +class WithingsEntityDescription(EntityDescription, WithingsEntityDescriptionMixin): + """Immutable class for describing withings data.""" + + +class WithingsEntity(CoordinatorEntity[WithingsDataUpdateCoordinator]): + """Base class for withings entities.""" + + entity_description: WithingsEntityDescription + _attr_has_entity_name = True + + def __init__( + self, + coordinator: WithingsDataUpdateCoordinator, + description: WithingsEntityDescription, + ) -> None: + """Initialize the Withings entity.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"withings_{coordinator.config_entry.unique_id}_{description.measurement.value}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, str(coordinator.config_entry.unique_id))}, + manufacturer="Withings", + ) diff --git a/homeassistant/components/withings/manifest.json b/homeassistant/components/withings/manifest.json index 29201c7e66e..edc8aab83b7 100644 --- a/homeassistant/components/withings/manifest.json +++ b/homeassistant/components/withings/manifest.json @@ -1,7 +1,8 @@ { "domain": "withings", "name": "Withings", - "codeowners": ["@vangorra"], + "after_dependencies": ["cloud"], + "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 c2cdd89a17f..42f5ac18f2f 100644 --- a/homeassistant/components/withings/sensor.py +++ b/homeassistant/components/withings/sensor.py @@ -23,13 +23,8 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .common import ( - BaseWithingsSensor, - UpdateType, - WithingsEntityDescription, - async_get_data_manager, -) from .const import ( + DOMAIN, SCORE_POINTS, UOM_BEATS_PER_MINUTE, UOM_BREATHS_PER_MINUTE, @@ -37,6 +32,8 @@ from .const import ( UOM_MMHG, Measurement, ) +from .coordinator import WithingsDataUpdateCoordinator +from .entity import WithingsEntity, WithingsEntityDescription @dataclass @@ -51,344 +48,310 @@ SENSORS = [ 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, - update_type=UpdateType.POLL, ), 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, - update_type=UpdateType.POLL, ), 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, - update_type=UpdateType.POLL, ), 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, - update_type=UpdateType.POLL, ), 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, - update_type=UpdateType.POLL, ), 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, entity_registry_enabled_default=False, - update_type=UpdateType.POLL, ), 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, - update_type=UpdateType.POLL, ), 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, - update_type=UpdateType.POLL, ), 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, - update_type=UpdateType.POLL, ), 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, ), 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, ), 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, ), 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, - update_type=UpdateType.POLL, ), 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, ), 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", state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - update_type=UpdateType.POLL, ), 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, - update_type=UpdateType.POLL, ), 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, ), 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, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - update_type=UpdateType.POLL, ), 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, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - update_type=UpdateType.POLL, ), 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, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - update_type=UpdateType.POLL, ), 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, entity_registry_enabled_default=False, - update_type=UpdateType.POLL, ), 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", state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - update_type=UpdateType.POLL, ), 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, entity_registry_enabled_default=False, - update_type=UpdateType.POLL, ), 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, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - update_type=UpdateType.POLL, ), 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, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - update_type=UpdateType.POLL, ), 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, - update_type=UpdateType.POLL, ), 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, - update_type=UpdateType.POLL, ), 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, - update_type=UpdateType.POLL, ), 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, entity_registry_enabled_default=False, - update_type=UpdateType.POLL, ), 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, ), 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, ), 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, entity_registry_enabled_default=False, - update_type=UpdateType.POLL, ), 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, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - update_type=UpdateType.POLL, ), ] @@ -399,14 +362,12 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the sensor config entry.""" - data_manager = await async_get_data_manager(hass, entry) + coordinator: WithingsDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - entities = [WithingsHealthSensor(data_manager, attribute) for attribute in SENSORS] - - async_add_entities(entities, True) + async_add_entities(WithingsSensor(coordinator, attribute) for attribute in SENSORS) -class WithingsHealthSensor(BaseWithingsSensor, SensorEntity): +class WithingsSensor(WithingsEntity, SensorEntity): """Implementation of a Withings sensor.""" entity_description: WithingsSensorEntityDescription @@ -414,4 +375,12 @@ class WithingsHealthSensor(BaseWithingsSensor, SensorEntity): @property def native_value(self) -> None | str | int | float: """Return the state of the entity.""" - return self._state_data + return self.coordinator.data[self.entity_description.measurement] + + @property + def available(self) -> bool: + """Return if the sensor is available.""" + return ( + super().available + and self.entity_description.measurement in self.coordinator.data + ) diff --git a/homeassistant/components/withings/strings.json b/homeassistant/components/withings/strings.json index 8f8a32c95e7..ea925f535e3 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,104 @@ "create_entry": { "default": "Successfully authenticated with Withings." } + }, + "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 2f9e1162763..430ee067486 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 9ba3fd2cb3d..6f3bae03bfa 100644 --- a/homeassistant/components/wled/coordinator.py +++ b/homeassistant/components/wled/coordinator.py @@ -46,7 +46,7 @@ class WLEDDataUpdateCoordinator(DataUpdateCoordinator[WLEDDevice]): @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 1eb8074bbc1..6675118e565 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 @@ class WLEDSegmentLight(WLEDEntity, LightEntity): # 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 c31f8e1277e..977c76025ac 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 668b90159b5..7d1431c093b 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 @@ SENSORS: tuple[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 @@ SENSORS: tuple[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 @@ SENSORS: tuple[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 @@ SENSORS: tuple[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 @@ SENSORS: tuple[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 @@ SENSORS: tuple[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 @@ SENSORS: tuple[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 @@ SENSORS: tuple[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 9fc6573b112..5791732dfbe 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 99b875c1642..680684e96df 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 75546fdac1a..954279366be 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 60883a0acf5..b4d60011658 100644 --- a/homeassistant/components/wolflink/sensor.py +++ b/homeassistant/components/wolflink/sensor.py @@ -57,14 +57,10 @@ class WolfLinkSensor(CoordinatorEntity, SensorEntity): """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 @@ class WolfLinkSensor(CoordinatorEntity, SensorEntity): "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/__init__.py b/homeassistant/components/workday/__init__.py index 84ed67a36dd..558e0aa9ecf 100644 --- a/homeassistant/components/workday/__init__.py +++ b/homeassistant/components/workday/__init__.py @@ -6,19 +6,43 @@ from holidays import list_supported_countries from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryError +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from .const import CONF_COUNTRY, CONF_PROVINCE, PLATFORMS +from .const import CONF_COUNTRY, CONF_PROVINCE, DOMAIN, PLATFORMS async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Workday from a config entry.""" - country: str = entry.options[CONF_COUNTRY] + country: str | None = entry.options.get(CONF_COUNTRY) province: str | None = entry.options.get(CONF_PROVINCE) + if country and country not in list_supported_countries(): + async_create_issue( + hass, + DOMAIN, + "bad_country", + is_fixable=True, + is_persistent=True, + severity=IssueSeverity.ERROR, + translation_key="bad_country", + translation_placeholders={"title": entry.title}, + data={"entry_id": entry.entry_id, "country": None}, + ) raise ConfigEntryError(f"Selected country {country} is not valid") - if province and province not in list_supported_countries()[country]: + if country and province and province not in list_supported_countries()[country]: + async_create_issue( + hass, + DOMAIN, + "bad_province", + is_fixable=True, + is_persistent=True, + severity=IssueSeverity.ERROR, + translation_key="bad_province", + translation_placeholders={CONF_COUNTRY: country, "title": entry.title}, + data={"entry_id": entry.entry_id, "country": country}, + ) raise ConfigEntryError( f"Selected province {province} for country {country} is not valid" ) diff --git a/homeassistant/components/workday/binary_sensor.py b/homeassistant/components/workday/binary_sensor.py index ad18c8863d6..5daea6ce129 100644 --- a/homeassistant/components/workday/binary_sensor.py +++ b/homeassistant/components/workday/binary_sensor.py @@ -5,7 +5,6 @@ from datetime import date, timedelta from typing import Any from holidays import ( - DateLike, HolidayBase, __version__ as python_holidays_version, country_holidays, @@ -45,6 +44,26 @@ from .const import ( ) +def validate_dates(holiday_list: list[str]) -> list[str]: + """Validate and adds to list of dates to add or remove.""" + calc_holidays: list[str] = [] + for add_date in holiday_list: + if add_date.find(",") > 0: + dates = add_date.split(",", maxsplit=1) + d1 = dt_util.parse_date(dates[0]) + d2 = dt_util.parse_date(dates[1]) + if d1 is None or d2 is None: + LOGGER.error("Incorrect dates in date range: %s", add_date) + continue + _range: timedelta = d2 - d1 + for i in range(_range.days + 1): + day = d1 + timedelta(days=i) + calc_holidays.append(day.strftime("%Y-%m-%d")) + continue + calc_holidays.append(add_date) + return calc_holidays + + def valid_country(value: Any) -> str: """Validate that the given country is supported.""" value = cv.string(value) @@ -119,32 +138,39 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Workday sensor.""" - add_holidays: list[DateLike] = entry.options[CONF_ADD_HOLIDAYS] + add_holidays: list[str] = entry.options[CONF_ADD_HOLIDAYS] remove_holidays: list[str] = entry.options[CONF_REMOVE_HOLIDAYS] - country: str = entry.options[CONF_COUNTRY] + country: str | None = entry.options.get(CONF_COUNTRY) days_offset: int = int(entry.options[CONF_OFFSET]) excludes: list[str] = entry.options[CONF_EXCLUDES] province: str | None = entry.options.get(CONF_PROVINCE) sensor_name: str = entry.options[CONF_NAME] workdays: list[str] = entry.options[CONF_WORKDAYS] + year: int = (dt_util.now() + timedelta(days=days_offset)).year - cls: HolidayBase = country_holidays(country, subdiv=province, years=year) - obj_holidays: HolidayBase = country_holidays( - country, - subdiv=province, - years=year, - language=cls.default_language, - ) + if country: + cls: HolidayBase = country_holidays(country, subdiv=province, years=year) + obj_holidays: HolidayBase = country_holidays( + country, + subdiv=province, + years=year, + language=cls.default_language, + ) + else: + obj_holidays = HolidayBase() + + calc_add_holidays: list[str] = validate_dates(add_holidays) + calc_remove_holidays: list[str] = validate_dates(remove_holidays) # Add custom holidays try: - obj_holidays.append(add_holidays) + obj_holidays.append(calc_add_holidays) # type: ignore[arg-type] except ValueError as error: LOGGER.error("Could not add custom holidays: %s", error) # Remove holidays - for remove_holiday in remove_holidays: + for remove_holiday in calc_remove_holidays: try: # is this formatted as a date? if dt_util.parse_date(remove_holiday): diff --git a/homeassistant/components/workday/config_flow.py b/homeassistant/components/workday/config_flow.py index 54c6196b75b..6be7e119876 100644 --- a/homeassistant/components/workday/config_flow.py +++ b/homeassistant/components/workday/config_flow.py @@ -52,7 +52,7 @@ def add_province_to_schema( ) -> vol.Schema: """Update schema with province from country.""" all_countries = list_supported_countries() - if not all_countries[country]: + if not all_countries.get(country): return schema province_list = [NONE_SENTINEL, *all_countries[country]] @@ -69,35 +69,55 @@ def add_province_to_schema( return vol.Schema({**DATA_SCHEMA_OPT.schema, **add_schema}) +def _is_valid_date_range(check_date: str, error: type[HomeAssistantError]) -> bool: + """Validate date range.""" + if check_date.find(",") > 0: + dates = check_date.split(",", maxsplit=1) + for date in dates: + if dt_util.parse_date(date) is None: + raise error("Incorrect date in range") + return True + return False + + def validate_custom_dates(user_input: dict[str, Any]) -> None: """Validate custom dates for add/remove holidays.""" - for add_date in user_input[CONF_ADD_HOLIDAYS]: - if dt_util.parse_date(add_date) is None: + if ( + not _is_valid_date_range(add_date, AddDateRangeError) + and dt_util.parse_date(add_date) is None + ): raise AddDatesError("Incorrect date") - cls: HolidayBase = country_holidays(user_input[CONF_COUNTRY]) year: int = dt_util.now().year - obj_holidays: HolidayBase = country_holidays( - user_input[CONF_COUNTRY], - subdiv=user_input.get(CONF_PROVINCE), - years=year, - language=cls.default_language, - ) + if country := user_input[CONF_COUNTRY]: + cls = country_holidays(country) + obj_holidays = country_holidays( + country=country, + subdiv=user_input.get(CONF_PROVINCE), + years=year, + language=cls.default_language, + ) + else: + obj_holidays = HolidayBase(years=year) for remove_date in user_input[CONF_REMOVE_HOLIDAYS]: - if dt_util.parse_date(remove_date) is None: - if obj_holidays.get_named(remove_date) == []: - raise RemoveDatesError("Incorrect date or name") + if ( + not _is_valid_date_range(remove_date, RemoveDateRangeError) + and dt_util.parse_date(remove_date) is None + and obj_holidays.get_named(remove_date) == [] + ): + raise RemoveDatesError("Incorrect date or name") DATA_SCHEMA_SETUP = vol.Schema( { vol.Required(CONF_NAME, default=DEFAULT_NAME): TextSelector(), - vol.Required(CONF_COUNTRY): SelectSelector( + vol.Optional(CONF_COUNTRY, default=NONE_SENTINEL): SelectSelector( SelectSelectorConfig( - options=list(list_supported_countries()), + options=[NONE_SENTINEL, *list(list_supported_countries())], mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_COUNTRY, ) ), } @@ -208,6 +228,9 @@ class WorkdayConfigFlow(ConfigFlow, domain=DOMAIN): errors: dict[str, str] = {} if user_input is not None: combined_input: dict[str, Any] = {**self.data, **user_input} + + if combined_input.get(CONF_COUNTRY, NONE_SENTINEL) == NONE_SENTINEL: + combined_input[CONF_COUNTRY] = None if combined_input.get(CONF_PROVINCE, NONE_SENTINEL) == NONE_SENTINEL: combined_input[CONF_PROVINCE] = None @@ -217,8 +240,12 @@ class WorkdayConfigFlow(ConfigFlow, domain=DOMAIN): ) except AddDatesError: errors["add_holidays"] = "add_holiday_error" + except AddDateRangeError: + errors["add_holidays"] = "add_holiday_range_error" except RemoveDatesError: errors["remove_holidays"] = "remove_holiday_error" + except RemoveDateRangeError: + errors["remove_holidays"] = "remove_holiday_range_error" except NotImplementedError: self.async_abort(reason="incorrect_province") @@ -278,8 +305,12 @@ class WorkdayOptionsFlowHandler(OptionsFlowWithConfigEntry): ) except AddDatesError: errors["add_holidays"] = "add_holiday_error" + except AddDateRangeError: + errors["add_holidays"] = "add_holiday_range_error" except RemoveDatesError: errors["remove_holidays"] = "remove_holiday_error" + except RemoveDateRangeError: + errors["remove_holidays"] = "remove_holiday_range_error" else: LOGGER.debug("abort_check in options with %s", combined_input) try: @@ -322,9 +353,17 @@ class AddDatesError(HomeAssistantError): """Exception for error adding dates.""" +class AddDateRangeError(HomeAssistantError): + """Exception for error adding dates.""" + + class RemoveDatesError(HomeAssistantError): """Exception for error removing dates.""" +class RemoveDateRangeError(HomeAssistantError): + """Exception for error removing dates.""" + + class CountryNotExist(HomeAssistantError): """Exception country does not exist error.""" diff --git a/homeassistant/components/workday/repairs.py b/homeassistant/components/workday/repairs.py new file mode 100644 index 00000000000..ff643ecc2cb --- /dev/null +++ b/homeassistant/components/workday/repairs.py @@ -0,0 +1,124 @@ +"""Repairs platform for the Workday integration.""" + +from __future__ import annotations + +from typing import Any, cast + +from holidays import list_supported_countries +import voluptuous as vol + +from homeassistant import data_entry_flow +from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_COUNTRY +from homeassistant.core import HomeAssistant +from homeassistant.helpers.selector import ( + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) + +from .config_flow import NONE_SENTINEL +from .const import CONF_PROVINCE + + +class CountryFixFlow(RepairsFlow): + """Handler for an issue fixing flow.""" + + def __init__(self, entry: ConfigEntry, country: str | None) -> None: + """Create flow.""" + self.entry = entry + self.country: str | None = country + super().__init__() + + async def async_step_init( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the first step of a fix flow.""" + if self.country: + return await self.async_step_province() + return await self.async_step_country() + + async def async_step_country( + self, user_input: dict[str, Any] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the country step of a fix flow.""" + if user_input is not None: + all_countries = list_supported_countries() + if not all_countries[user_input[CONF_COUNTRY]]: + options = dict(self.entry.options) + new_options = {**options, **user_input, CONF_PROVINCE: None} + self.hass.config_entries.async_update_entry( + self.entry, options=new_options + ) + await self.hass.config_entries.async_reload(self.entry.entry_id) + return self.async_create_entry(data={}) + self.country = user_input[CONF_COUNTRY] + return await self.async_step_province() + + return self.async_show_form( + step_id="country", + data_schema=vol.Schema( + { + vol.Required(CONF_COUNTRY): SelectSelector( + SelectSelectorConfig( + options=sorted(list_supported_countries()), + mode=SelectSelectorMode.DROPDOWN, + ) + ) + } + ), + description_placeholders={"title": self.entry.title}, + ) + + async def async_step_province( + self, user_input: dict[str, Any] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the province step of a fix flow.""" + if user_input and user_input.get(CONF_PROVINCE): + if user_input.get(CONF_PROVINCE, NONE_SENTINEL) == NONE_SENTINEL: + user_input[CONF_PROVINCE] = None + options = dict(self.entry.options) + new_options = {**options, **user_input, CONF_COUNTRY: self.country} + self.hass.config_entries.async_update_entry(self.entry, options=new_options) + await self.hass.config_entries.async_reload(self.entry.entry_id) + return self.async_create_entry(data={}) + + assert self.country + country_provinces = list_supported_countries()[self.country] + return self.async_show_form( + step_id="province", + data_schema=vol.Schema( + { + vol.Optional(CONF_PROVINCE, default=NONE_SENTINEL): SelectSelector( + SelectSelectorConfig( + options=[NONE_SENTINEL, *country_provinces], + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_PROVINCE, + ) + ), + } + ), + description_placeholders={ + CONF_COUNTRY: self.country, + "title": self.entry.title, + }, + ) + + +async def async_create_fix_flow( + hass: HomeAssistant, + issue_id: str, + data: dict[str, Any] | None, +) -> RepairsFlow: + """Create flow.""" + entry = None + if data and (entry_id := data.get("entry_id")): + entry_id = cast(str, entry_id) + entry = hass.config_entries.async_get_entry(entry_id) + + if data and entry: + # Country or province does not exist + return CountryFixFlow(entry, data.get("country")) + + return ConfirmRepairFlow() diff --git a/homeassistant/components/workday/strings.json b/homeassistant/components/workday/strings.json index a217a7a36b1..a4c2baf31c8 100644 --- a/homeassistant/components/workday/strings.json +++ b/homeassistant/components/workday/strings.json @@ -26,15 +26,17 @@ "excludes": "List of workdays to exclude", "days_offset": "Days offset", "workdays": "List of workdays", - "add_holidays": "Add custom holidays as YYYY-MM-DD", - "remove_holidays": "Remove holidays as YYYY-MM-DD or by using partial of name", + "add_holidays": "Add custom holidays as YYYY-MM-DD or as range using `,` as separator", + "remove_holidays": "Remove holidays as YYYY-MM-DD, as range using `,` as separator or by using partial of name", "province": "State, Territory, Province, Region of Country" } } }, "error": { "add_holiday_error": "Incorrect format on date (YYYY-MM-DD)", - "remove_holiday_error": "Incorrect format on date (YYYY-MM-DD) or holiday name not found" + "add_holiday_range_error": "Incorrect format on date range (YYYY-MM-DD,YYYY-MM-DD)", + "remove_holiday_error": "Incorrect format on date (YYYY-MM-DD) or holiday name not found", + "remove_holiday_range_error": "Incorrect format on date range (YYYY-MM-DD,YYYY-MM-DD)" } }, "options": { @@ -61,11 +63,18 @@ }, "error": { "add_holiday_error": "[%key:component::workday::config::error::add_holiday_error%]", + "add_holiday_range_error": "[%key:component::workday::config::error::add_holiday_range_error%]", "remove_holiday_error": "[%key:component::workday::config::error::remove_holiday_error%]", + "remove_holiday_range_error": "[%key:component::workday::config::error::remove_holiday_range_error%]", "already_configured": "Service with this configuration already exist" } }, "selector": { + "country": { + "options": { + "none": "No country" + } + }, "province": { "options": { "none": "No subdivision" @@ -83,5 +92,48 @@ "holiday": "Holidays" } } + }, + "issues": { + "bad_country": { + "title": "Configured Country for {title} does not exist", + "fix_flow": { + "step": { + "country": { + "title": "Select country for {title}", + "description": "Select a country to use for your Workday sensor.", + "data": { + "country": "[%key:component::workday::config::step::user::data::country%]" + } + }, + "province": { + "title": "Select province for {title}", + "description": "Select a province in country {country} to use for your Workday sensor.", + "data": { + "province": "[%key:component::workday::config::step::options::data::province%]" + }, + "data_description": { + "province": "State, Territory, Province, Region of Country" + } + } + } + } + }, + "bad_province": { + "title": "Configured province in country {country} for {title} does not exist", + "fix_flow": { + "step": { + "province": { + "title": "[%key:component::workday::issues::bad_country::fix_flow::step::province::title%]", + "description": "[%key:component::workday::issues::bad_country::fix_flow::step::province::description%]", + "data": { + "province": "[%key:component::workday::config::step::options::data::province%]" + }, + "data_description": { + "province": "[%key:component::workday::issues::bad_country::fix_flow::step::province::data_description::province%]" + } + } + } + } + } } } diff --git a/homeassistant/components/ws66i/media_player.py b/homeassistant/components/ws66i/media_player.py index b5c87fbc0f3..7119002cbc4 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 @@ class Ws66iZone(CoordinatorEntity[Ws66iDataUpdateCoordinator], MediaPlayerEntity 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/wyoming/manifest.json b/homeassistant/components/wyoming/manifest.json index 810092094d1..ddb5407e1ce 100644 --- a/homeassistant/components/wyoming/manifest.json +++ b/homeassistant/components/wyoming/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/wyoming", "iot_class": "local_push", - "requirements": ["wyoming==1.1.0"] + "requirements": ["wyoming==1.2.0"] } diff --git a/homeassistant/components/wyoming/wake_word.py b/homeassistant/components/wyoming/wake_word.py index 0e7fb3c4429..d4cbd9b9263 100644 --- a/homeassistant/components/wyoming/wake_word.py +++ b/homeassistant/components/wyoming/wake_word.py @@ -5,7 +5,7 @@ import logging from wyoming.audio import AudioChunk, AudioStart from wyoming.client import AsyncTcpClient -from wyoming.wake import Detection +from wyoming.wake import Detect, Detection from homeassistant.components import wake_word from homeassistant.config_entries import ConfigEntry @@ -46,7 +46,7 @@ class WyomingWakeWordProvider(wake_word.WakeWordDetectionEntity): wake_service = service.info.wake[0] self._supported_wake_words = [ - wake_word.WakeWord(ww_id=ww.name, name=ww.name) + wake_word.WakeWord(id=ww.name, name=ww.description or ww.name) for ww in wake_service.models ] self._attr_name = wake_service.name @@ -58,7 +58,7 @@ class WyomingWakeWordProvider(wake_word.WakeWordDetectionEntity): return self._supported_wake_words async def _async_process_audio_stream( - self, stream: AsyncIterable[tuple[bytes, int]] + self, stream: AsyncIterable[tuple[bytes, int]], wake_word_id: str | None ) -> wake_word.DetectionResult | None: """Try to detect one or more wake words in an audio stream. @@ -72,6 +72,11 @@ class WyomingWakeWordProvider(wake_word.WakeWordDetectionEntity): try: async with AsyncTcpClient(self.service.host, self.service.port) as client: + # Inform client which wake word we want to detect (None = default) + await client.write_event( + Detect(names=[wake_word_id] if wake_word_id else None).event() + ) + await client.write_event( AudioStart( rate=16000, @@ -98,10 +103,20 @@ class WyomingWakeWordProvider(wake_word.WakeWordDetectionEntity): break if Detection.is_type(event.type): - # Successful detection + # Possible detection detection = Detection.from_event(event) _LOGGER.info(detection) + if wake_word_id and (detection.name != wake_word_id): + _LOGGER.warning( + "Expected wake word %s but got %s, skipping", + wake_word_id, + detection.name, + ) + wake_task = asyncio.create_task(client.read_event()) + pending.add(wake_task) + continue + # Retrieve queued audio queued_audio: list[tuple[bytes, int]] | None = None if audio_task in pending: @@ -111,7 +126,7 @@ class WyomingWakeWordProvider(wake_word.WakeWordDetectionEntity): queued_audio = [audio_task.result()] return wake_word.DetectionResult( - ww_id=detection.name, + wake_word_id=detection.name, timestamp=detection.timestamp, queued_audio=queued_audio, ) diff --git a/homeassistant/components/xbox/base_sensor.py b/homeassistant/components/xbox/base_sensor.py index ffbbee8637d..9aecb100df0 100644 --- a/homeassistant/components/xbox/base_sensor.py +++ b/homeassistant/components/xbox/base_sensor.py @@ -20,11 +20,15 @@ class XboxBaseSensorEntity(CoordinatorEntity[XboxUpdateCoordinator]): 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 @@ class XboxBaseSensorEntity(CoordinatorEntity[XboxUpdateCoordinator]): 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/yamaha_musiccast/__init__.py b/homeassistant/components/yamaha_musiccast/__init__.py index c3851074365..9e8b8fed530 100644 --- a/homeassistant/components/yamaha_musiccast/__init__.py +++ b/homeassistant/components/yamaha_musiccast/__init__.py @@ -136,24 +136,9 @@ class MusicCastEntity(CoordinatorEntity[MusicCastDataUpdateCoordinator]): ) -> 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 a8ca6162c91..8ef9df1ba2f 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 526ee3c42ab..e7102f9c74b 100644 --- a/homeassistant/components/yardian/coordinator.py +++ b/homeassistant/components/yardian/coordinator.py @@ -39,7 +39,6 @@ class YardianUpdateCoordinator(DataUpdateCoordinator[YardianDeviceState]): hass, _LOGGER, name=entry.title, - update_method=self._async_update_data, update_interval=SCAN_INTERVAL, always_update=False, ) diff --git a/homeassistant/components/yardian/services.yaml b/homeassistant/components/yardian/services.yaml new file mode 100644 index 00000000000..a8d05133f51 --- /dev/null +++ b/homeassistant/components/yardian/services.yaml @@ -0,0 +1,14 @@ +start_irrigation: + target: + entity: + integration: yardian + domain: switch + fields: + duration: + required: true + default: 6 + selector: + number: + min: 1 + max: 1440 + unit_of_measurement: "minutes" diff --git a/homeassistant/components/yardian/strings.json b/homeassistant/components/yardian/strings.json index 6577c99456c..f841f3d3ed1 100644 --- a/homeassistant/components/yardian/strings.json +++ b/homeassistant/components/yardian/strings.json @@ -16,5 +16,17 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "services": { + "start_irrigation": { + "name": "Start irrigation", + "description": "Starts the irrigation.", + "fields": { + "duration": { + "name": "Duration", + "description": "Duration for the target to be turned on." + } + } + } } } diff --git a/homeassistant/components/yardian/switch.py b/homeassistant/components/yardian/switch.py index af5703e0fd4..8598e4a8732 100644 --- a/homeassistant/components/yardian/switch.py +++ b/homeassistant/components/yardian/switch.py @@ -3,15 +3,23 @@ from __future__ import annotations from typing import Any +import voluptuous as vol + from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DEFAULT_WATERING_DURATION, DOMAIN from .coordinator import YardianUpdateCoordinator +SERVICE_START_IRRIGATION = "start_irrigation" +SERVICE_SCHEMA_START_IRRIGATION = { + vol.Required("duration"): cv.positive_int, +} + async def async_setup_entry( hass: HomeAssistant, @@ -28,6 +36,13 @@ async def async_setup_entry( for i in range(len(coordinator.data.zones)) ) + platform = entity_platform.async_get_current_platform() + platform.async_register_entity_service( + SERVICE_START_IRRIGATION, + SERVICE_SCHEMA_START_IRRIGATION, + "async_turn_on", + ) + class YardianSwitch(CoordinatorEntity[YardianUpdateCoordinator], SwitchEntity): """Representation of a Yardian switch.""" diff --git a/homeassistant/components/yeelight/manifest.json b/homeassistant/components/yeelight/manifest.json index 993cc6ca4fa..ecb8c1f35d2 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.36.1"], "zeroconf": [ { "type": "_miio._udp.local.", diff --git a/homeassistant/components/yolink/binary_sensor.py b/homeassistant/components/yolink/binary_sensor.py index 38ea7d46537..e65896cdd42 100644 --- a/homeassistant/components/yolink/binary_sensor.py +++ b/homeassistant/components/yolink/binary_sensor.py @@ -136,3 +136,8 @@ class YoLinkBinarySensorEntity(YoLinkEntity, BinarySensorEntity): state.get(self.entity_description.state_key) ) self.async_write_ha_state() + + @property + def available(self) -> bool: + """Return true is device is available.""" + return super().available and self.coordinator.dev_online diff --git a/homeassistant/components/yolink/const.py b/homeassistant/components/yolink/const.py index 935889a0368..9fc4dac8ada 100644 --- a/homeassistant/components/yolink/const.py +++ b/homeassistant/components/yolink/const.py @@ -8,3 +8,4 @@ ATTR_DEVICE_NAME = "name" ATTR_DEVICE_STATE = "state" ATTR_DEVICE_ID = "deviceId" YOLINK_EVENT = f"{DOMAIN}_event" +YOLINK_OFFLINE_TIME = 32400 diff --git a/homeassistant/components/yolink/coordinator.py b/homeassistant/components/yolink/coordinator.py index 9055b2d044e..f2c942caab9 100644 --- a/homeassistant/components/yolink/coordinator.py +++ b/homeassistant/components/yolink/coordinator.py @@ -2,7 +2,7 @@ from __future__ import annotations import asyncio -from datetime import timedelta +from datetime import UTC, datetime, timedelta import logging from yolink.device import YoLinkDevice @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import ATTR_DEVICE_STATE, DOMAIN +from .const import ATTR_DEVICE_STATE, DOMAIN, YOLINK_OFFLINE_TIME _LOGGER = logging.getLogger(__name__) @@ -37,6 +37,7 @@ class YoLinkCoordinator(DataUpdateCoordinator[dict]): ) self.device = device self.paired_device = paired_device + self.dev_online = True async def _async_update_data(self) -> dict: """Fetch device state.""" @@ -44,6 +45,13 @@ class YoLinkCoordinator(DataUpdateCoordinator[dict]): async with asyncio.timeout(10): device_state_resp = await self.device.fetch_state() device_state = device_state_resp.data.get(ATTR_DEVICE_STATE) + device_reporttime = device_state_resp.data.get("reportAt") + if device_reporttime is not None: + rpt_time_delta = ( + datetime.now(tz=UTC).replace(tzinfo=None) + - datetime.strptime(device_reporttime, "%Y-%m-%dT%H:%M:%S.%fZ") + ).total_seconds() + self.dev_online = rpt_time_delta < YOLINK_OFFLINE_TIME if self.paired_device is not None and device_state is not None: paried_device_state_resp = await self.paired_device.fetch_state() paried_device_state = paried_device_state_resp.data.get( diff --git a/homeassistant/components/yolink/sensor.py b/homeassistant/components/yolink/sensor.py index e4d0aa38fbe..2fc4a2b0725 100644 --- a/homeassistant/components/yolink/sensor.py +++ b/homeassistant/components/yolink/sensor.py @@ -92,6 +92,7 @@ BATTERY_POWER_SENSOR = [ 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, @@ -260,3 +261,8 @@ class YoLinkSensorEntity(YoLinkEntity, SensorEntity): return self._attr_native_value = attr_val self.async_write_ha_state() + + @property + def available(self) -> bool: + """Return true is device is available.""" + return super().available and self.coordinator.dev_online diff --git a/homeassistant/components/zamg/weather.py b/homeassistant/components/zamg/weather.py index ff98496bd40..98e08106dca 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 @@ class ZamgWeather(CoordinatorEntity, WeatherEntity): 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 b85f9f0fd83..bf0984d3989 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -11,7 +11,7 @@ from ipaddress import IPv4Address, IPv6Address 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 @@ CONFIG_SCHEMA = vol.Schema( @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 @@ class ZeroconfDiscovery: 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 117744a2775..53475588cfe 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.98.0"] + "requirements": ["zeroconf==0.115.1"] } diff --git a/homeassistant/components/zerproc/light.py b/homeassistant/components/zerproc/light.py index 884f87d36f6..c6be3c70e65 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 @@ class ZerprocLight(LightEntity): "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 662ddd080e0..08db98cff6f 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -33,9 +33,6 @@ from .core.const import ( CONF_USB_PATH, CONF_ZIGPY, DATA_ZHA, - DATA_ZHA_CONFIG, - DATA_ZHA_DEVICE_TRIGGER_CACHE, - DATA_ZHA_GATEWAY, DOMAIN, PLATFORMS, SIGNAL_ADD_ENTITIES, @@ -43,6 +40,7 @@ from .core.const import ( ) 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}) @@ -81,11 +79,9 @@ _LOGGER = logging.getLogger(__name__) 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 @@ -120,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, {}) + zha_data = get_zha_data(hass) - for platform in PLATFORMS: - zha_data.setdefault(platform, []) - - 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 @@ -139,8 +133,6 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b _LOGGER.debug("ZHA storage file does not exist or was already removed") # Load and cache device trigger information early - zha_data.setdefault(DATA_ZHA_DEVICE_TRIGGER_CACHE, {}) - device_registry = dr.async_get(hass) radio_mgr = ZhaRadioManager.from_config_entry(hass, config_entry) @@ -154,14 +146,14 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b if dev_entry is None: continue - zha_data[DATA_ZHA_DEVICE_TRIGGER_CACHE][dev_entry.id] = ( + zha_data.device_trigger_cache[dev_entry.id] = ( str(dev.ieee), get_device_automation_triggers(dev), ) - _LOGGER.debug("Trigger cache: %s", zha_data[DATA_ZHA_DEVICE_TRIGGER_CACHE]) + _LOGGER.debug("Trigger cache: %s", zha_data.device_trigger_cache) - zha_gateway = ZHAGateway(hass, config, config_entry) + zha_gateway = ZHAGateway(hass, zha_data.yaml_config, config_entry) async def async_zha_shutdown(): """Handle shutdown tasks.""" @@ -172,13 +164,13 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b # be in when we get here in failure cases with contextlib.suppress(KeyError): for platform in PLATFORMS: - del hass.data[DATA_ZHA][platform] + del zha_data.platforms[platform] config_entry.async_on_unload(async_zha_shutdown) try: await zha_gateway.async_initialize() - except Exception: # pylint: disable=broad-except + except Exception: if RadioType[config_entry.data[CONF_RADIO_TYPE]] == RadioType.ezsp: try: await repairs.warn_on_wrong_silabs_firmware( @@ -212,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) @@ -241,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 b6794e909d8..21cacfa5dd4 100644 --- a/homeassistant/components/zha/alarm_control_panel.py +++ b/homeassistant/components/zha/alarm_control_panel.py @@ -35,11 +35,10 @@ from .core.const import ( 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 3d44103e225..f63fb9d09de 100644 --- a/homeassistant/components/zha/api.py +++ b/homeassistant/components/zha/api.py @@ -9,33 +9,22 @@ from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH 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 89d5294e1c4..e125a8085f6 100644 --- a/homeassistant/components/zha/backup.py +++ b/homeassistant/components/zha/backup.py @@ -3,8 +3,7 @@ import logging 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 50cfb783370..c32bd5eeb67 100644 --- a/homeassistant/components/zha/binary_sensor.py +++ b/homeassistant/components/zha/binary_sensor.py @@ -26,10 +26,10 @@ from .core.const import ( 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 7a4132115b8..4114a3dea7c 100644 --- a/homeassistant/components/zha/button.py +++ b/homeassistant/components/zha/button.py @@ -14,7 +14,8 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect 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 cf868ef8b7b..5cbe2684ab4 100644 --- a/homeassistant/components/zha/climate.py +++ b/homeassistant/components/zha/climate.py @@ -45,13 +45,13 @@ from .core import discovery 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/cluster_handlers/security.py b/homeassistant/components/zha/core/cluster_handlers/security.py index f31830f0bd8..9c74a14daa8 100644 --- a/homeassistant/components/zha/core/cluster_handlers/security.py +++ b/homeassistant/components/zha/core/cluster_handlers/security.py @@ -369,12 +369,11 @@ class IASZoneClusterHandler(ClusterHandler): ieee = self.cluster.endpoint.device.application.state.node_info.ieee try: - res = await self.write_attributes_safe({"cie_addr": ieee}) + await self.write_attributes_safe({"cie_addr": ieee}) self.debug( - "wrote cie_addr: %s to '%s' cluster: %s", + "wrote cie_addr: %s to '%s' cluster", str(ieee), self._cluster.ep_attribute, - res[0], ) except HomeAssistantError as ex: self.debug( diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index 9569fc49659..b37fa7ffe6d 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -184,7 +184,6 @@ CUSTOM_CONFIGURATION = "custom_configuration" 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" diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py index 60bf78e516c..8f5b087f068 100644 --- a/homeassistant/components/zha/core/device.py +++ b/homeassistant/components/zha/core/device.py @@ -25,6 +25,7 @@ from homeassistant.backports.functools import cached_property 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, @@ -420,7 +421,9 @@ class ZHADevice(LogMixin): """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}" ) @@ -658,7 +661,8 @@ class ZHADevice(LogMixin): ) 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 92b68bdb159..a56e7044d3a 100644 --- a/homeassistant/components/zha/core/discovery.py +++ b/homeassistant/components/zha/core/discovery.py @@ -4,10 +4,11 @@ from __future__ import annotations 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 @@ from .cluster_handlers import ( # noqa: F401 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 @@ class ProbeEndpoint: 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 @@ class ProbeEndpoint: 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 @@ class GroupProbe: @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 @@ class GroupProbe: 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 @@ class GroupProbe: 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 bdef5ac46af..c87ee60d6b3 100644 --- a/homeassistant/components/zha/core/endpoint.py +++ b/homeassistant/components/zha/core/endpoint.py @@ -16,6 +16,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send 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 @@ class Endpoint: 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 @@ class Endpoint: 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 5cc2cd9a4b9..c5d04dda961 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -10,7 +10,6 @@ import itertools import logging import re import time -import traceback from typing import TYPE_CHECKING, Any, NamedTuple from zigpy.application import ControllerApplication @@ -46,9 +45,6 @@ from .const import ( 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 .const import ( ) 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 @@ class ZHAGateway: 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 @@ class ZHAGateway: 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 @@ class ZHAGateway: 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( @@ -225,8 +217,8 @@ class ZHAGateway: else: break - self._hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] = self - self._hass.data[DATA_ZHA][DATA_ZHA_BRIDGE_ID] = str(self.coordinator_ieee) + 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 @@ -301,7 +293,7 @@ class ZHAGateway: # 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: @@ -311,7 +303,7 @@ class ZHAGateway: address """ async_dispatcher_send( - self._hass, + self.hass, ZHA_GW_MSG, { ATTR_TYPE: ZHA_GW_MSG_DEVICE_JOINED, @@ -327,7 +319,7 @@ class ZHAGateway: """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, @@ -344,7 +336,7 @@ class ZHAGateway: 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.""" @@ -359,7 +351,7 @@ class ZHAGateway: 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( @@ -371,7 +363,7 @@ class ZHAGateway: 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 @@ -399,7 +391,7 @@ class ZHAGateway: 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, @@ -416,9 +408,11 @@ class ZHAGateway: 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.""" @@ -427,14 +421,14 @@ class ZHAGateway: 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, @@ -488,9 +482,10 @@ class ZHAGateway: ] # 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, ) @@ -508,7 +503,7 @@ class ZHAGateway: _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: @@ -582,9 +577,11 @@ class ZHAGateway: ) -> 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))}, @@ -600,7 +597,7 @@ class ZHAGateway: """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 @@ -645,7 +642,7 @@ class ZHAGateway: 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, @@ -659,7 +656,7 @@ class ZHAGateway: 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, @@ -667,7 +664,7 @@ class ZHAGateway: }, ) 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( @@ -681,7 +678,7 @@ class ZHAGateway: 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, @@ -816,21 +813,20 @@ class LogRelayHandler(logging.Handler): 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 ebea2f4ac41..519668052e0 100644 --- a/homeassistant/components/zha/core/group.py +++ b/homeassistant/components/zha/core/group.py @@ -11,6 +11,7 @@ import zigpy.group 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 @@ class ZHAGroupMember(LogMixin): @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 @@ class ZHAGroup(LogMixin): 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 7b0d062738b..4df546b449c 100644 --- a/homeassistant/components/zha/core/helpers.py +++ b/homeassistant/components/zha/core/helpers.py @@ -7,7 +7,9 @@ from __future__ import annotations 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 @@ from zigpy.zcl.foundation import CommandSchema 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 03fdc7e37c1..74f724bdc49 100644 --- a/homeassistant/components/zha/core/registries.py +++ b/homeassistant/components/zha/core/registries.py @@ -4,6 +4,7 @@ from __future__ import annotations 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 @@ CLIENT_CLUSTER_HANDLER_REGISTRY: DictRegistry[ ] = 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 @@ class ZHAEntityRegistry: def get_entity( self, - component: str, + component: Platform, manufacturer: str, model: str, cluster_handlers: list[ClusterHandler], @@ -294,7 +297,7 @@ class ZHAEntityRegistry: ) -> 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 @@ class ZHAEntityRegistry: 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 @@ class ZHAEntityRegistry: 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 @@ class ZHAEntityRegistry: 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 @@ class ZHAEntityRegistry: 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 @@ class ZHAEntityRegistry: 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 @@ class ZHAEntityRegistry: 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 @@ class ZHAEntityRegistry: 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 0d7062173ca..f2aed0390f3 100644 --- a/homeassistant/components/zha/cover.py +++ b/homeassistant/components/zha/cover.py @@ -33,11 +33,11 @@ from .core.const import ( 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 bda346624dd..ea27c58eb19 100644 --- a/homeassistant/components/zha/device_tracker.py +++ b/homeassistant/components/zha/device_tracker.py @@ -15,10 +15,10 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback 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 7a479443377..a2ae734b8fc 100644 --- a/homeassistant/components/zha/device_trigger.py +++ b/homeassistant/components/zha/device_trigger.py @@ -14,8 +14,8 @@ from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType from . import DOMAIN as ZHA_DOMAIN -from .core.const import DATA_ZHA, DATA_ZHA_DEVICE_TRIGGER_CACHE, ZHA_EVENT -from .core.helpers import async_get_zha_device +from .core.const import ZHA_EVENT +from .core.helpers import async_get_zha_device, get_zha_data CONF_SUBTYPE = "subtype" DEVICE = "device" @@ -32,13 +32,13 @@ def _get_device_trigger_data(hass: HomeAssistant, device_id: str) -> tuple[str, # First, try checking to see if the device itself is accessible try: zha_device = async_get_zha_device(hass, device_id) - except KeyError: + 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 hass.data[DATA_ZHA][DATA_ZHA_DEVICE_TRIGGER_CACHE][device_id] + return get_zha_data(hass).device_trigger_cache[device_id] async def async_validate_trigger_config( diff --git a/homeassistant/components/zha/diagnostics.py b/homeassistant/components/zha/diagnostics.py index 966f35fe98b..0fa1de5ff0e 100644 --- a/homeassistant/components/zha/diagnostics.py +++ b/homeassistant/components/zha/diagnostics.py @@ -25,14 +25,10 @@ from .core.const import ( 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 f2b16a37834..da34b829907 100644 --- a/homeassistant/components/zha/entity.py +++ b/homeassistant/components/zha/entity.py @@ -26,14 +26,12 @@ from homeassistant.helpers.typing import EventType 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 @@ class BaseZhaEntity(LogMixin, entity.Entity): 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 @@ class BaseZhaEntity(LogMixin, entity.Entity): """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 @@ class BaseZhaEntity(LogMixin, entity.Entity): 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 @@ class ZhaEntity(BaseZhaEntity, RestoreEntity): 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 a24272c9a7a..73b128db109 100644 --- a/homeassistant/components/zha/fan.py +++ b/homeassistant/components/zha/fan.py @@ -28,12 +28,8 @@ from homeassistant.util.percentage import ( 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 2ec42431498..967d0fc9134 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -47,13 +47,12 @@ from .core.const import ( 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 1e68e95c881..9bac9a59a38 100644 --- a/homeassistant/components/zha/lock.py +++ b/homeassistant/components/zha/lock.py @@ -20,10 +20,10 @@ from homeassistant.helpers.typing import StateType 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 3610cd41425..9ce3a3eb7db 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -21,16 +21,17 @@ "universal_silabs_flasher" ], "requirements": [ - "bellows==0.36.4", + "bellows==0.36.5", "pyserial==3.5", "pyserial-asyncio==0.6", - "zha-quirks==0.0.103", + "zha-quirks==0.0.104", "zigpy-deconz==0.21.1", - "zigpy==0.57.1", - "zigpy-xbee==0.18.2", + "zigpy==0.57.2", + "zigpy-xbee==0.18.3", "zigpy-zigate==0.11.0", - "zigpy-znp==0.11.4", - "universal-silabs-flasher==0.0.14" + "zigpy-znp==0.11.5", + "universal-silabs-flasher==0.0.14", + "pyserial-asyncio-fast==0.11" ], "usb": [ { diff --git a/homeassistant/components/zha/number.py b/homeassistant/components/zha/number.py index c12060eb2a8..b6876155312 100644 --- a/homeassistant/components/zha/number.py +++ b/homeassistant/components/zha/number.py @@ -20,10 +20,10 @@ from .core.const import ( 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 df30a85cd7b..ca030600751 100644 --- a/homeassistant/components/zha/radio_manager.py +++ b/homeassistant/components/zha/radio_manager.py @@ -26,12 +26,11 @@ from .core.const import ( 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 @@ -145,7 +144,7 @@ class ZhaRadioManager: """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( diff --git a/homeassistant/components/zha/select.py b/homeassistant/components/zha/select.py index 018f24675e7..fa2e124fd05 100644 --- a/homeassistant/components/zha/select.py +++ b/homeassistant/components/zha/select.py @@ -23,11 +23,11 @@ from .core.const import ( 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 535733230b9..1e166675b5b 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -57,10 +57,10 @@ from .core.const import ( 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 a4c699d515b..86cadb62519 100644 --- a/homeassistant/components/zha/siren.py +++ b/homeassistant/components/zha/siren.py @@ -25,7 +25,6 @@ from .core import discovery 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 @@ from .core.const import ( 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 87738e821ea..79354325fb2 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 Step 2 (and Step 2 only) to 'Flash the Silicon Labs radio 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 8707dda629f..eff8f727c1c 100644 --- a/homeassistant/components/zha/switch.py +++ b/homeassistant/components/zha/switch.py @@ -20,10 +20,10 @@ from .core.const import ( 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 97862bd36f0..51941248f03 100644 --- a/homeassistant/components/zha/websocket_api.py +++ b/homeassistant/components/zha/websocket_api.py @@ -16,6 +16,7 @@ import zigpy.zdo.types as zdo_types 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 @@ from .core.const import ( CLUSTER_TYPE_IN, CLUSTER_TYPE_OUT, CUSTOM_CONFIGURATION, - DATA_ZHA, - DATA_ZHA_GATEWAY, DOMAIN, EZSP_OVERWRITE_EUI64, GROUP_ID, @@ -77,6 +76,7 @@ from .core.helpers import ( 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 @@ def async_load_api(hass: HomeAssistant) -> 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/__init__.py b/homeassistant/components/zwave_js/__init__.py index b56298e36ba..b9a26630406 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -5,6 +5,7 @@ import asyncio from collections import defaultdict from collections.abc import Coroutine from contextlib import suppress +import logging from typing import Any from zwave_js_server.client import Client as ZwaveClient @@ -29,6 +30,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, CONF_URL, EVENT_HOMEASSISTANT_STOP, + EVENT_LOGGING_CHANGED, Platform, ) from homeassistant.core import Event, HomeAssistant, callback @@ -93,6 +95,7 @@ from .const import ( DATA_CLIENT, DOMAIN, EVENT_DEVICE_ADDED_TO_REGISTRY, + LIB_LOGGER, LOGGER, USER_AGENT, ZWAVE_JS_NOTIFICATION_EVENT, @@ -105,6 +108,8 @@ from .discovery import ( async_discover_single_value, ) from .helpers import ( + async_disable_server_logging_if_needed, + async_enable_server_logging_if_needed, async_enable_statistics, get_device_id, get_device_id_ext, @@ -249,6 +254,24 @@ class DriverEvents: elif opted_in is False: await driver.async_disable_statistics() + async def handle_logging_changed(_: Event | None = None) -> None: + """Handle logging changed event.""" + if LIB_LOGGER.isEnabledFor(logging.DEBUG): + await async_enable_server_logging_if_needed( + self.hass, self.config_entry, driver + ) + else: + await async_disable_server_logging_if_needed( + self.hass, self.config_entry, driver + ) + + # Set up server logging on setup if needed + await handle_logging_changed() + + self.config_entry.async_on_unload( + self.hass.bus.async_listen(EVENT_LOGGING_CHANGED, handle_logging_changed) + ) + # Check for nodes that no longer exist and remove them stored_devices = dr.async_entries_for_config_entry( self.dev_reg, self.config_entry.entry_id @@ -741,6 +764,7 @@ class NodeEvents: ATTR_DOMAIN: DOMAIN, ATTR_NODE_ID: notification.node.node_id, ATTR_HOME_ID: driver.controller.home_id, + ATTR_ENDPOINT: notification.endpoint_idx, ATTR_DEVICE_ID: device.id, ATTR_COMMAND_CLASS: notification.command_class, } @@ -901,6 +925,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: unload_ok = all(await asyncio.gather(*tasks)) if tasks else True + if hasattr(driver_events, "driver"): + await async_disable_server_logging_if_needed(hass, entry, driver_events.driver) if DATA_CLIENT_LISTEN_TASK in info: await disconnect_client(hass, entry) diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index d93745f7a66..8658dc1cc1f 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -82,7 +82,6 @@ from .const import ( from .helpers import ( async_enable_statistics, async_get_node_from_device_id, - async_update_data_collection_preference, get_device_id, ) @@ -411,15 +410,15 @@ def async_register_api(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, websocket_remove_node) websocket_api.async_register_command(hass, websocket_remove_failed_node) websocket_api.async_register_command(hass, websocket_replace_failed_node) - websocket_api.async_register_command(hass, websocket_begin_healing_network) + websocket_api.async_register_command(hass, websocket_begin_rebuilding_routes) websocket_api.async_register_command( - hass, websocket_subscribe_heal_network_progress + hass, websocket_subscribe_rebuild_routes_progress ) - websocket_api.async_register_command(hass, websocket_stop_healing_network) + websocket_api.async_register_command(hass, websocket_stop_rebuilding_routes) websocket_api.async_register_command(hass, websocket_refresh_node_info) websocket_api.async_register_command(hass, websocket_refresh_node_values) websocket_api.async_register_command(hass, websocket_refresh_node_cc_values) - websocket_api.async_register_command(hass, websocket_heal_node) + websocket_api.async_register_command(hass, websocket_rebuild_node_routes) websocket_api.async_register_command(hass, websocket_set_config_parameter) websocket_api.async_register_command(hass, websocket_get_config_parameters) websocket_api.async_register_command(hass, websocket_subscribe_log_updates) @@ -490,6 +489,7 @@ async def websocket_network_status( "state": "connected" if client.connected else "disconnected", "driver_version": client_version_info.driver_version, "server_version": client_version_info.server_version, + "server_logging_enabled": client.server_logging_enabled, }, "controller": { "home_id": controller.home_id, @@ -511,7 +511,7 @@ async def websocket_network_status( "supported_function_types": controller.supported_function_types, "suc_node_id": controller.suc_node_id, "supports_timers": controller.supports_timers, - "is_heal_network_active": controller.is_heal_network_active, + "is_rebuilding_routes": controller.is_rebuilding_routes, "inclusion_state": controller.inclusion_state, "rf_region": controller.rf_region, "status": controller.status, @@ -1379,14 +1379,14 @@ async def websocket_remove_failed_node( @websocket_api.require_admin @websocket_api.websocket_command( { - vol.Required(TYPE): "zwave_js/begin_healing_network", + vol.Required(TYPE): "zwave_js/begin_rebuilding_routes", vol.Required(ENTRY_ID): str, } ) @websocket_api.async_response @async_handle_failed_command @async_get_entry -async def websocket_begin_healing_network( +async def websocket_begin_rebuilding_routes( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], @@ -1394,10 +1394,10 @@ async def websocket_begin_healing_network( client: Client, driver: Driver, ) -> None: - """Begin healing the Z-Wave network.""" + """Begin rebuilding Z-Wave routes.""" controller = driver.controller - result = await controller.async_begin_healing_network() + result = await controller.async_begin_rebuilding_routes() connection.send_result( msg[ID], result, @@ -1407,13 +1407,13 @@ async def websocket_begin_healing_network( @websocket_api.require_admin @websocket_api.websocket_command( { - vol.Required(TYPE): "zwave_js/subscribe_heal_network_progress", + vol.Required(TYPE): "zwave_js/subscribe_rebuild_routes_progress", vol.Required(ENTRY_ID): str, } ) @websocket_api.async_response @async_get_entry -async def websocket_subscribe_heal_network_progress( +async def websocket_subscribe_rebuild_routes_progress( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], @@ -1421,7 +1421,7 @@ async def websocket_subscribe_heal_network_progress( client: Client, driver: Driver, ) -> None: - """Subscribe to heal Z-Wave network status updates.""" + """Subscribe to rebuild Z-Wave routes status updates.""" controller = driver.controller @callback @@ -1434,30 +1434,39 @@ async def websocket_subscribe_heal_network_progress( def forward_event(key: str, event: dict) -> None: connection.send_message( websocket_api.event_message( - msg[ID], {"event": event["event"], "heal_node_status": event[key]} + msg[ID], {"event": event["event"], "rebuild_routes_status": event[key]} ) ) connection.subscriptions[msg["id"]] = async_cleanup msg[DATA_UNSUBSCRIBE] = unsubs = [ - controller.on("heal network progress", partial(forward_event, "progress")), - controller.on("heal network done", partial(forward_event, "result")), + controller.on("rebuild routes progress", partial(forward_event, "progress")), + controller.on("rebuild routes done", partial(forward_event, "result")), ] - connection.send_result(msg[ID], controller.heal_network_progress) + if controller.rebuild_routes_progress: + connection.send_result( + msg[ID], + { + node.node_id: status + for node, status in controller.rebuild_routes_progress.items() + }, + ) + else: + connection.send_result(msg[ID], None) @websocket_api.require_admin @websocket_api.websocket_command( { - vol.Required(TYPE): "zwave_js/stop_healing_network", + vol.Required(TYPE): "zwave_js/stop_rebuilding_routes", vol.Required(ENTRY_ID): str, } ) @websocket_api.async_response @async_handle_failed_command @async_get_entry -async def websocket_stop_healing_network( +async def websocket_stop_rebuilding_routes( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], @@ -1465,9 +1474,9 @@ async def websocket_stop_healing_network( client: Client, driver: Driver, ) -> None: - """Stop healing the Z-Wave network.""" + """Stop rebuilding Z-Wave routes.""" controller = driver.controller - result = await controller.async_stop_healing_network() + result = await controller.async_stop_rebuilding_routes() connection.send_result( msg[ID], result, @@ -1477,14 +1486,14 @@ async def websocket_stop_healing_network( @websocket_api.require_admin @websocket_api.websocket_command( { - vol.Required(TYPE): "zwave_js/heal_node", + vol.Required(TYPE): "zwave_js/rebuild_node_routes", vol.Required(DEVICE_ID): str, } ) @websocket_api.async_response @async_handle_failed_command @async_get_node -async def websocket_heal_node( +async def websocket_rebuild_node_routes( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], @@ -1495,7 +1504,7 @@ async def websocket_heal_node( assert driver is not None # The node comes from the driver instance. controller = driver.controller - result = await controller.async_heal_node(node) + result = await controller.async_rebuild_node_routes(node) connection.send_result( msg[ID], result, @@ -1866,7 +1875,10 @@ async def websocket_update_data_collection_preference( ) -> None: """Update preference for data collection and enable/disable collection.""" opted_in = msg[OPTED_IN] - async_update_data_collection_preference(hass, entry, opted_in) + if entry.data.get(CONF_DATA_COLLECTION_OPTED_IN) != opted_in: + new_data = entry.data.copy() + new_data[CONF_DATA_COLLECTION_OPTED_IN] = opted_in + hass.config_entries.async_update_entry(entry, data=new_data) if opted_in: await async_enable_statistics(driver) diff --git a/homeassistant/components/zwave_js/const.py b/homeassistant/components/zwave_js/const.py index 5ee8b300603..34c6fa3363e 100644 --- a/homeassistant/components/zwave_js/const.py +++ b/homeassistant/components/zwave_js/const.py @@ -31,10 +31,12 @@ CONF_DATA_COLLECTION_OPTED_IN = "data_collection_opted_in" DOMAIN = "zwave_js" DATA_CLIENT = "client" +DATA_OLD_SERVER_LOG_LEVEL = "old_server_log_level" EVENT_DEVICE_ADDED_TO_REGISTRY = f"{DOMAIN}_device_added_to_registry" LOGGER = logging.getLogger(__package__) +LIB_LOGGER = logging.getLogger("zwave_js_server") # constants extra state attributes ATTR_RESERVED_VALUES = "reserved_values" # ConfigurationValue number entities diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index c879cc1f5b4..0a3f61fd824 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -160,6 +160,8 @@ class ZWaveValueDiscoverySchema(DataclassMustHaveAtLeastOne): writeable: bool | None = None # [optional] the value's states map must include ANY of these key/value pairs any_available_states: set[tuple[int, str]] | None = None + # [optional] the value's value must match this value + value: Any | None = None @dataclass @@ -378,6 +380,61 @@ DISCOVERY_SCHEMAS = [ ) ], ), + # Fibaro Shutter Fibaro FGR223 + # Combine both switch_multilevel endpoints into shutter_tilt + # if operating mode (151) is set to venetian blind (2) + ZWaveDiscoverySchema( + platform=Platform.COVER, + hint="shutter_tilt", + manufacturer_id={0x010F}, + product_id={0x1000, 0x1001}, + product_type={0x0303}, + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.SWITCH_MULTILEVEL}, + property={CURRENT_VALUE_PROPERTY}, + endpoint={1}, + type={ValueType.NUMBER}, + ), + data_template=CoverTiltDataTemplate( + current_tilt_value_id=ZwaveValueID( + property_=CURRENT_VALUE_PROPERTY, + command_class=CommandClass.SWITCH_MULTILEVEL, + endpoint=2, + ), + target_tilt_value_id=ZwaveValueID( + property_=TARGET_VALUE_PROPERTY, + command_class=CommandClass.SWITCH_MULTILEVEL, + endpoint=2, + ), + ), + required_values=[ + ZWaveValueDiscoverySchema( + command_class={CommandClass.CONFIGURATION}, + property={151}, + endpoint={0}, + value={2}, + ) + ], + ), + # Fibaro Shutter Fibaro FGR223 + # Disable endpoint 2 (slat), + # as these are either combined with endpoint one as shutter_tilt + # or it has no practical function. + # CC: Switch_Multilevel + ZWaveDiscoverySchema( + platform=Platform.COVER, + hint="shutter", + manufacturer_id={0x010F}, + product_id={0x1000, 0x1001}, + product_type={0x0303}, + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.SWITCH_MULTILEVEL}, + property={CURRENT_VALUE_PROPERTY}, + endpoint={2}, + type={ValueType.NUMBER}, + ), + entity_registry_enabled_default=False, + ), # Fibaro Nice BiDi-ZWave (IBT4ZWAVE) ZWaveDiscoverySchema( platform=Platform.COVER, @@ -588,6 +645,19 @@ DISCOVERY_SCHEMAS = [ ), 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 @@ -1223,6 +1293,9 @@ def check_value(value: ZwaveValue, schema: ZWaveValueDiscoverySchema) -> bool: ) ): return False + # check value + if schema.value is not None and value.value not in schema.value: + return False return True diff --git a/homeassistant/components/zwave_js/helpers.py b/homeassistant/components/zwave_js/helpers.py index 3b1faa40fa8..8774bcea73f 100644 --- a/homeassistant/components/zwave_js/helpers.py +++ b/homeassistant/components/zwave_js/helpers.py @@ -8,8 +8,14 @@ from typing import Any, cast import voluptuous as vol from zwave_js_server.client import Client as ZwaveClient -from zwave_js_server.const import CommandClass, ConfigurationValueType +from zwave_js_server.const import ( + LOG_LEVEL_MAP, + CommandClass, + ConfigurationValueType, + LogLevel, +) from zwave_js_server.model.driver import Driver +from zwave_js_server.model.log_config import LogConfig from zwave_js_server.model.node import Node as ZwaveNode from zwave_js_server.model.value import ( ConfigurationValue, @@ -39,9 +45,10 @@ from .const import ( ATTR_ENDPOINT, ATTR_PROPERTY, ATTR_PROPERTY_KEY, - CONF_DATA_COLLECTION_OPTED_IN, DATA_CLIENT, + DATA_OLD_SERVER_LOG_LEVEL, DOMAIN, + LIB_LOGGER, LOGGER, ) @@ -125,17 +132,70 @@ def get_value_of_zwave_value(value: ZwaveValue | None) -> Any | None: async def async_enable_statistics(driver: Driver) -> None: """Enable statistics on the driver.""" await driver.async_enable_statistics("Home Assistant", HA_VERSION) - await driver.async_enable_error_reporting() -@callback -def async_update_data_collection_preference( - hass: HomeAssistant, entry: ConfigEntry, preference: bool +async def async_enable_server_logging_if_needed( + hass: HomeAssistant, entry: ConfigEntry, driver: Driver ) -> None: - """Update data collection preference on config entry.""" - new_data = entry.data.copy() - new_data[CONF_DATA_COLLECTION_OPTED_IN] = preference - hass.config_entries.async_update_entry(entry, data=new_data) + """Enable logging of zwave-js-server in the lib.""" + # If lib log level is set to debug, we want to enable server logging. First we + # check if server log level is less verbose than library logging, and if so, set it + # to debug to match library logging. We will store the old server log level in + # hass.data so we can reset it later + if ( + not driver + or not driver.client.connected + or driver.client.server_logging_enabled + ): + return + + LOGGER.info("Enabling zwave-js-server logging") + if (curr_server_log_level := driver.log_config.level) and ( + LOG_LEVEL_MAP[curr_server_log_level] + ) > (lib_log_level := LIB_LOGGER.getEffectiveLevel()): + entry_data = hass.data[DOMAIN][entry.entry_id] + LOGGER.warning( + ( + "Server logging is set to %s and is currently less verbose " + "than library logging, setting server log level to %s to match" + ), + curr_server_log_level, + logging.getLevelName(lib_log_level), + ) + entry_data[DATA_OLD_SERVER_LOG_LEVEL] = curr_server_log_level + await driver.async_update_log_config(LogConfig(level=LogLevel.DEBUG)) + await driver.client.enable_server_logging() + LOGGER.info("Zwave-js-server logging is enabled") + + +async def async_disable_server_logging_if_needed( + hass: HomeAssistant, entry: ConfigEntry, driver: Driver +) -> None: + """Disable logging of zwave-js-server in the lib if still connected to server.""" + entry_data = hass.data[DOMAIN][entry.entry_id] + if ( + not driver + or not driver.client.connected + or not driver.client.server_logging_enabled + ): + return + LOGGER.info("Disabling zwave_js server logging") + if ( + DATA_OLD_SERVER_LOG_LEVEL in entry_data + and (old_server_log_level := entry_data.pop(DATA_OLD_SERVER_LOG_LEVEL)) + != driver.log_config.level + ): + LOGGER.info( + ( + "Server logging is currently set to %s as a result of server logging " + "being enabled. It is now being reset to %s" + ), + driver.log_config.level, + old_server_log_level, + ) + await driver.async_update_log_config(LogConfig(level=old_server_log_level)) + await driver.client.disable_server_logging() + LOGGER.info("Zwave-js-server logging is enabled") def get_valueless_base_unique_id(driver: Driver, node: ZwaveNode) -> str: diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index cfb2c239d8e..505196c43eb 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -3,13 +3,13 @@ "name": "Z-Wave", "codeowners": ["@home-assistant/z-wave"], "config_flow": true, - "dependencies": ["usb", "http", "repairs", "websocket_api"], + "dependencies": ["http", "repairs", "usb", "websocket_api"], "documentation": "https://www.home-assistant.io/integrations/zwave_js", "integration_type": "hub", "iot_class": "local_push", "loggers": ["zwave_js_server"], "quality_scale": "platinum", - "requirements": ["pyserial==3.5", "zwave-js-server-python==0.51.3"], + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.52.1"], "usb": [ { "vid": "0658", diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index 3c22288a1d6..8d42bcfb366 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -17,7 +17,7 @@ from zwave_js_server.model.controller.statistics import ControllerStatisticsData 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 @@ class ZWaveListSensor(ZwaveSensor): 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 @@ class ZWaveConfigParameterSensor(ZWaveListSensor): 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/update.py b/homeassistant/components/zwave_js/update.py index 5b7c157552a..6efae29e46e 100644 --- a/homeassistant/components/zwave_js/update.py +++ b/homeassistant/components/zwave_js/update.py @@ -62,7 +62,12 @@ class ZWaveNodeFirmwareUpdateExtraStoredData(ExtraStoredData): @classmethod def from_dict(cls, data: dict[str, Any]) -> ZWaveNodeFirmwareUpdateExtraStoredData: """Initialize the extra data from a dict.""" - if not (firmware_dict := data[ATTR_LATEST_VERSION_FIRMWARE]): + # If there was no firmware info stored, or if it's stale info, we don't restore + # anything. + if ( + not (firmware_dict := data[ATTR_LATEST_VERSION_FIRMWARE]) + or "normalizedVersion" not in firmware_dict + ): return cls(None) return cls(NodeFirmwareUpdateInfo.from_dict(firmware_dict)) @@ -206,11 +211,15 @@ class ZWaveNodeFirmwareUpdate(UpdateEntity): return try: - available_firmware_updates = ( - await self.driver.controller.async_get_available_firmware_updates( - self.node, API_KEY_FIRMWARE_UPDATE_SERVICE + # Retrieve all firmware updates including non-stable ones but filter + # non-stable channels out + available_firmware_updates = [ + update + for update in await self.driver.controller.async_get_available_firmware_updates( + self.node, API_KEY_FIRMWARE_UPDATE_SERVICE, True ) - ) + if update.channel == "stable" + ] except FailedZWaveCommand as err: LOGGER.debug( "Failed to get firmware updates for node %s: %s", @@ -267,9 +276,7 @@ class ZWaveNodeFirmwareUpdate(UpdateEntity): ) try: - await self.driver.controller.async_firmware_update_ota( - self.node, firmware.files - ) + await self.driver.controller.async_firmware_update_ota(self.node, firmware) except BaseZwaveJSServerError as err: self._unsub_firmware_events_and_reset_progress() raise HomeAssistantError(err) from err diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 02117c3ac5a..ed5ba79c1b4 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -148,6 +148,11 @@ EVENT_FLOW_DISCOVERED = "config_entry_discovered" 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 @@ class ConfigEntry: 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 @@ class ConfigEntry: 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 @@ class ConfigEntry: 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 @@ class ConfigEntry: 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 @@ class ConfigEntry: 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 @@ class ConfigEntry: 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 @@ class ConfigEntry: 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 @@ class ConfigEntry: 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 @@ class ConfigEntry: 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 @@ class ConfigEntriesFlowManager(data_entry_flow.FlowManager): 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 @@ class ConfigEntries: 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 @@ class ConfigEntries: """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 @@ class ConfigEntries: 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 @@ class ConfigEntries: 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 @@ class ConfigEntries: 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 @@ class ConfigEntries: 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 @@ class ConfigEntries: 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 @@ class ConfigEntries: ("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) diff --git a/homeassistant/const.py b/homeassistant/const.py index 8ce4d434083..c027875eae1 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -6,8 +6,8 @@ from typing import Final APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 -MINOR_VERSION: Final = 9 -PATCH_VERSION: Final = "3" +MINOR_VERSION: Final = 10 +PATCH_VERSION: Final = "0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0) @@ -288,6 +288,7 @@ EVENT_HOMEASSISTANT_STARTED: Final = "homeassistant_started" EVENT_HOMEASSISTANT_STOP: Final = "homeassistant_stop" EVENT_HOMEASSISTANT_FINAL_WRITE: Final = "homeassistant_final_write" EVENT_LOGBOOK_ENTRY: Final = "logbook_entry" +EVENT_LOGGING_CHANGED: Final = "logging_changed" EVENT_SERVICE_REGISTERED: Final = "service_registered" EVENT_SERVICE_REMOVED: Final = "service_removed" EVENT_STATE_CHANGED: Final = "state_changed" @@ -460,6 +461,9 @@ ATTR_HIDDEN: Final = "hidden" 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 f2921e244ab..a50d43c1344 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -6,13 +6,15 @@ of entities and react to changes. 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 @@ from urllib.parse import urlparse 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 @@ if TYPE_CHECKING: 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 @@ -304,8 +308,15 @@ class 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() @@ -707,7 +718,9 @@ class HomeAssistant: 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: @@ -951,7 +964,7 @@ class Event: { "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(), } @@ -1228,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, @@ -1251,6 +1250,7 @@ class State: 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) @@ -1263,16 +1263,15 @@ class State: 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: @@ -1307,12 +1306,12 @@ class State: ) 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. @@ -1337,6 +1336,7 @@ class State: ) return compressed_state + @cached_property def as_compressed_state_json(self) -> str: """Build a compressed JSON key value pair of a state for adds. @@ -1344,11 +1344,7 @@ class State: 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: @@ -1411,14 +1407,59 @@ class State: ) +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 @@ -1439,16 +1480,15 @@ class StateMachine: 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( @@ -1459,13 +1499,13 @@ class StateMachine: 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]: @@ -1483,21 +1523,22 @@ class StateMachine: 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. @@ -1526,9 +1567,7 @@ class StateMachine: """ 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 @@ -1577,7 +1616,7 @@ class StateMachine: 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." @@ -1589,7 +1628,9 @@ class StateMachine: 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( @@ -1599,6 +1640,7 @@ class StateMachine: 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. @@ -1612,7 +1654,7 @@ class StateMachine: 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 @@ -1650,6 +1692,7 @@ class StateMachine: 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 467fc3b5228..e22d4229511 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -138,8 +138,8 @@ class FlowManager(abc.ABC): 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 @@ class FlowManager(abc.ABC): """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 @@ class FlowManager(abc.ABC): 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( @@ -325,10 +320,17 @@ class FlowManager(abc.ABC): ) # If the result has changed from last result, fire event to update - # the frontend. - if ( - cur_step["step_id"] != result.get("step_id") - or result["type"] == FlowResultType.SHOW_PROGRESS + # the frontend. The result is considered to have changed if: + # - The step has changed + # - The step is same but result type is SHOW_PROGRESS and progress_action + # or description_placeholders has changed + if cur_step["step_id"] != result.get("step_id") or ( + result["type"] == FlowResultType.SHOW_PROGRESS + and ( + cur_step["progress_action"] != result.get("progress_action") + or cur_step["description_placeholders"] + != result.get("description_placeholders") + ) ): # Tell frontend to reload the flow state. self.hass.bus.async_fire( @@ -348,22 +350,20 @@ class FlowManager(abc.ABC): """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/application_credentials.py b/homeassistant/generated/application_credentials.py index 78c98bcc03d..8c9e3a57ddc 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -18,6 +18,7 @@ APPLICATION_CREDENTIALS = [ "netatmo", "senz", "spotify", + "twitch", "withings", "xbox", "yolink", diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index 7b0aa78d69e..c2b24b68d29 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -213,6 +213,10 @@ BLUETOOTH: list[dict[str, bool | str | int | list[int]]] = [ ], "manufacturer_id": 76, }, + { + "domain": "idasen_desk", + "service_uuid": "99fa0001-338a-1024-8a49-009c0215f78a", + }, { "connectable": False, "domain": "inkbird", @@ -300,6 +304,10 @@ BLUETOOTH: list[dict[str, bool | str | int | list[int]]] = [ "domain": "led_ble", "local_name": "AP-*", }, + { + "domain": "medcom_ble", + "service_uuid": "39b31fec-b63a-4ef7-b163-a7317872007f", + }, { "domain": "melnor", "manufacturer_data_start": [ diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 7d84dc87cbe..ef22ac4f653 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -23,6 +23,7 @@ FLOWS = { "adguard", "advantage_air", "aemet", + "aftership", "agent_dvr", "airly", "airnow", @@ -82,6 +83,7 @@ FLOWS = { "cloudflare", "co2signal", "coinbase", + "color_extractor", "comelit", "control4", "coolmaster", @@ -114,6 +116,7 @@ FLOWS = { "eafm", "easyenergy", "ecobee", + "ecoforest", "econet", "ecowitt", "edl21", @@ -205,11 +208,13 @@ FLOWS = { "huisbaasje", "hunterdouglas_powerview", "hvv_departures", + "hydrawise", "hyperion", "ialarm", "iaqualink", "ibeacon", "icloud", + "idasen_desk", "ifttt", "imap", "inkbird", @@ -267,6 +272,7 @@ FLOWS = { "matter", "mazda", "meater", + "medcom_ble", "melcloud", "melnor", "met", @@ -301,6 +307,7 @@ FLOWS = { "netatmo", "netgear", "nexia", + "nextbus", "nextcloud", "nextdns", "nfandroidtv", @@ -351,6 +358,7 @@ FLOWS = { "point", "poolsense", "powerwall", + "private_ble_device", "profiler", "progettihwsw", "prosegur", @@ -454,6 +462,7 @@ FLOWS = { "surepetcare", "switchbee", "switchbot", + "switchbot_cloud", "switcher_kis", "syncthing", "syncthru", @@ -472,6 +481,7 @@ FLOWS = { "tibber", "tile", "tilt_ble", + "todoist", "tolo", "tomorrowio", "toon", @@ -490,6 +500,7 @@ FLOWS = { "twentemilieu", "twilio", "twinkly", + "twitch", "ukraine_alarm", "unifi", "unifiprotect", @@ -515,8 +526,11 @@ FLOWS = { "volvooncall", "vulcan", "wallbox", + "waqi", "watttime", "waze_travel_time", + "weatherflow", + "weatherkit", "webostv", "wemo", "whirlpool", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index ef496e7b58b..1d9c2208ad0 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -68,7 +68,7 @@ "aftership": { "name": "AfterShip", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "cloud_polling" }, "agent_dvr": { @@ -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" }, @@ -870,7 +876,7 @@ "color_extractor": { "name": "ColorExtractor", "integration_type": "hub", - "config_flow": false + "config_flow": true }, "comed": { "name": "Commonwealth Edison (ComEd)", @@ -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", @@ -2484,7 +2501,7 @@ "hydrawise": { "name": "Hunter Hydrawise", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "cloud_polling" }, "hyperion": { @@ -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" } } }, @@ -3247,6 +3270,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "medcom_ble": { + "name": "Medcom Bluetooth", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "media_extractor": { "name": "Media Extractor", "integration_type": "hub", @@ -3706,9 +3735,8 @@ "supported_by": "overkiz" }, "nextbus": { - "name": "NextBus", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "cloud_polling" }, "nextcloud": { @@ -4320,6 +4348,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 +5536,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 +5841,7 @@ "todoist": { "name": "Todoist", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "cloud_polling" }, "tolo": { @@ -5930,7 +5975,7 @@ "name": "Trend", "integration_type": "hub", "config_flow": false, - "iot_class": "local_push" + "iot_class": "calculated" }, "tuya": { "name": "Tuya", @@ -5976,7 +6021,7 @@ "twitch": { "name": "Twitch", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "cloud_polling" }, "twitter": { @@ -5987,9 +6032,16 @@ }, "u_tec": { "name": "U-tec", - "iot_standards": [ - "zwave" - ] + "integrations": { + "ultraloq": { + "integration_type": "virtual", + "config_flow": false, + "iot_standards": [ + "zwave" + ], + "name": "Ultraloq" + } + } }, "ubiquiti": { "name": "Ubiquiti", @@ -6275,7 +6327,7 @@ "waqi": { "name": "World Air Quality Index (WAQI)", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "cloud_polling" }, "waterfurnace": { @@ -6295,6 +6347,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "weatherflow": { + "name": "WeatherFlow", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push" + }, "webhook": { "name": "Webhook", "integration_type": "hub", @@ -6768,6 +6826,7 @@ "mobile_app", "moehlenhoff_alpha2", "moon", + "nextbus", "nmap_tracker", "plant", "proximity", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 3874a06ab4b..36ddfd68479 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -44,7 +44,7 @@ HOMEKIT = { "always_discover": True, "domain": "roku", }, - "EB-*": { + "EB": { "always_discover": True, "domain": "ecobee", }, @@ -386,6 +386,11 @@ ZEROCONF = { "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 9c2492d65e8..064579a95d3 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -12,6 +12,7 @@ from urllib.parse import urlparse 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 @@ class DeviceEntry: "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 @@ class DeviceEntry: 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 @@ class DeviceRegistryItems(UserDict[str, _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 586824b4495..306e8b51d63 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 60aab156144..e416d939914 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 @@ from homeassistant.util.logging import catch_log_exception _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 dispatcher_connect( 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] - return async_remove_dispatcher + if signal not in dispatchers: + dispatchers[signal] = {} + + 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 e946c41d3b8..9b16b0c24fd 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -4,15 +4,25 @@ from __future__ import annotations 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 @@ -40,8 +50,12 @@ from homeassistant.exceptions import ( InvalidStateError, NoEntitySpecifiedError, ) -from homeassistant.loader import bind_hass -from homeassistant.util import dt as dt_util, ensure_unique_string, slugify +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 @@ -60,8 +74,6 @@ _T = TypeVar("_T") _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 @@ -76,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 @@ -181,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.""" @@ -272,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 @@ -301,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. @@ -660,7 +709,7 @@ class Entity(ABC): 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. @@ -847,14 +896,20 @@ class Entity(ABC): 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 try: hass.states.async_set( - entity_id, state, attr, self.force_update, self._context + 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) @@ -1060,18 +1115,19 @@ class Entity(ABC): 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 - self.hass.data[DATA_ENTITY_SOURCE][self.entity_id] = info + entity_sources(self.hass)[self.entity_id] = entity_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 @@ -1202,8 +1258,21 @@ class Entity(ABC): 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 ff2ca255279..42de4749215 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -20,6 +20,7 @@ from typing import TYPE_CHECKING, Any, Literal, NotRequired, TypedDict, TypeVar, 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 @@ class RegistryEntry: 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 @@ class RegistryEntry: 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 @@ class RegistryEntry: "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 @@ class RegistryEntry: 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 @@ class EntityRegistryStore(storage.Store[dict[str, list[dict[str, Any]]]]): 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 @@ class EntityRegistryItems(UserDict[str, "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 40364b7b367..2da8a48be98 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -1194,7 +1194,7 @@ class TrackTemplateResultInfo: ) _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, @@ -1435,6 +1435,13 @@ def async_track_point_in_utc_time( 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( @@ -1444,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 @@ -1477,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 ddaede44962..0a9a6efd525 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 c9d8de23b96..a1d045eb542 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -911,7 +911,7 @@ class _ScriptRun: 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"): @@ -933,7 +933,7 @@ class _ScriptRun: 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 @@ -1047,7 +1047,7 @@ class _ScriptRun: @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: @@ -1107,9 +1107,8 @@ class _QueuedScriptRun(_ScriptRun): 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() diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 3eb537f9649..4532e1a00ae 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -73,7 +73,7 @@ ALL_SERVICE_DESCRIPTIONS_CACHE = "all_service_descriptions_cache" @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 9f280db6c98..b0754c13c7c 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -929,15 +929,13 @@ class DomainStates: 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 diff --git a/homeassistant/helpers/translation.py b/homeassistant/helpers/translation.py index 79ac3a0c5b7..41ad591d878 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 0ee653b42bd..bc7deceefef 100644 --- a/homeassistant/helpers/trigger_template_entity.py +++ b/homeassistant/helpers/trigger_template_entity.py @@ -119,6 +119,7 @@ class TriggerBaseEntity(Entity): # 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 @@ class TriggerBaseEntity(Entity): """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/loader.py b/homeassistant/loader.py index 37e470c1178..9d4d6e880f8 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -25,6 +25,7 @@ from awesomeversion import ( 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 @@ from .util.json import JSON_DECODE_EXCEPTIONS, json_loads # 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 @@ class IntegrationNotFound(LoaderError): 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.""" diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 714c11baf4a..51d03a40971 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -1,39 +1,38 @@ -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.36.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.3 +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 +bluetooth-data-tools==1.12.0 certifi>=2021.5.30 ciso8601==2.3.0 -cryptography==41.0.3 -dbus-fast==1.95.2 +cryptography==41.0.4 +dbus-fast==2.11.0 fnv-hash-fast==0.4.1 ha-av==10.1.1 hass-nabucasa==0.71.0 hassil==1.2.5 home-assistant-bluetooth==1.10.3 -home-assistant-frontend==20230911.0 -home-assistant-intents==2023.9.22 +home-assistant-frontend==20231002.0 +home-assistant-intents==2023.10.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 +Pillow==10.0.1 pip>=21.3.1 psutil-home-assistant==0.0.1 PyJWT==2.8.0 @@ -46,14 +45,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 +webrtc-noise-gain==1.2.3 yarl==1.9.2 -zeroconf==0.98.0 +zeroconf==0.115.1 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 @@ -71,9 +70,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 +111,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 @@ -127,8 +126,9 @@ multidict>=6.0.2 # Version 2.0 added typing, prevent accidental fallbacks backoff>=2.0 -# Require to avoid issues with decorators (#93904). v2 has breaking changes. -pydantic>=1.10.8,<2.0 +# Required to avoid breaking (#101042). +# v2 has breaking changes (#99218). +pydantic==1.10.12 # Breaks asyncio # https://github.com/pubnub/python/issues/130 @@ -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 @@ -173,3 +173,8 @@ pysnmp==1000000000.0.0 # The get-mac package has been replaced with getmac. Installing get-mac alongside getmac # breaks getmac due to them both sharing the same python package name inside 'getmac'. get-mac==1000000000.0.0 + +# We want to skip the binary wheels for the 'charset-normalizer' packages. +# They are build with mypyc, but causes issues with our wheel builder. +# In order to do so, we need to constrain the version. +charset-normalizer==3.2.0 diff --git a/homeassistant/runner.py b/homeassistant/runner.py index ed49db37f97..10521f80135 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 38fa9cc2463..9a63c73590b 100644 --- a/homeassistant/scripts/check_config.py +++ b/homeassistant/scripts/check_config.py @@ -30,7 +30,6 @@ import homeassistant.util.yaml.loader as yaml_loader 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 check(config_dir, secrets=False): 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/aiohttp.py b/homeassistant/util/aiohttp.py index 6e2cfc5325d..ceb5e502221 100644 --- a/homeassistant/util/aiohttp.py +++ b/homeassistant/util/aiohttp.py @@ -66,6 +66,11 @@ class MockRequest: """Return the body as text.""" return MockStreamReader(self._content) + @property + def body_exists(self) -> bool: + """Return True if request has HTTP BODY, False otherwise.""" + return bool(self._text) + async def json(self, loads: JSONDecoder = json_loads) -> Any: """Return the body as JSON.""" return loads(self._text) diff --git a/homeassistant/util/language.py b/homeassistant/util/language.py index 4ec8c74ffa9..73db81c91ce 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/homeassistant/util/yaml/loader.py b/homeassistant/util/yaml/loader.py index 2e31b212f1f..5f18a729130 100644 --- a/homeassistant/util/yaml/loader.py +++ b/homeassistant/util/yaml/loader.py @@ -203,7 +203,7 @@ def _parse_yaml( # If configuration file is empty YAML returns None # We convert that to an empty dict return ( - yaml.load(content, Loader=lambda stream: loader(stream, secrets)) + yaml.load(content, Loader=lambda stream: loader(stream, secrets)) # type: ignore[arg-type] or NodeDictClass() ) diff --git a/mypy.ini b/mypy.ini index 82cce328c6a..c2ecac66946 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 @@ -1852,6 +1902,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.london_underground.*] +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.lookin.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -1892,6 +1952,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 +2362,26 @@ 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.poolsense.*] +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 +2392,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 +2973,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 +3193,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 00000000000..db4b2d4a5d7 --- /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 de21d99335f..9e153b6cc4f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.9.3" +version = "2023.10.0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" @@ -25,10 +25,9 @@ requires-python = ">=3.11.0" dependencies = [ "aiohttp==3.8.5", "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", @@ -41,16 +40,16 @@ dependencies = [ "lru-dict==1.2.0", "PyJWT==2.8.0", # PyJWT has loose dependency. We want the latest one. - "cryptography==41.0.3", + "cryptography==41.0.4", # 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 +99,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 +109,7 @@ load-plugins = [ persistent = false extension-pkg-allow-list = [ "av.audio.stream", + "av.logging", "av.stream", "ciso8601", "orjson", @@ -288,6 +289,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 +460,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 +502,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 e7a3b0fc4c5..60eb2359ba5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,10 +3,9 @@ # Home Assistant Core aiohttp==3.8.5 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 @@ -16,15 +15,15 @@ ifaddr==0.2.0 Jinja2==3.1.2 lru-dict==1.2.0 PyJWT==2.8.0 -cryptography==41.0.3 +cryptography==41.0.4 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 e9eaf4acdc9..cbf8738cb2e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2,10 +2,10 @@ -r requirements.txt # homeassistant.components.aemet -AEMET-OpenData==0.4.4 +AEMET-OpenData==0.4.5 # homeassistant.components.aladdin_connect -AIOAladdinConnect==0.1.57 +AIOAladdinConnect==0.1.58 # homeassistant.components.honeywell AIOSomecomfort==0.0.17 @@ -37,18 +37,19 @@ 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 # homeassistant.components.sighthound # homeassistant.components.tensorflow -Pillow==10.0.0 +Pillow==10.0.1 # homeassistant.components.plex -PlexAPI==4.13.2 +PlexAPI==4.15.3 # homeassistant.components.progettihwsw -ProgettiHWSW==0.1.1 +ProgettiHWSW==0.1.3 # homeassistant.components.bluetooth_tracker # PyBluez==0.22 @@ -96,7 +97,7 @@ PyRMVtransport==0.3.3 PySocks==1.7.1 # homeassistant.components.switchbot -PySwitchbot==0.39.1 +PySwitchbot==0.40.1 # homeassistant.components.switchmate PySwitchmate==0.5.1 @@ -128,7 +129,7 @@ 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 @@ -185,7 +186,7 @@ aio-georss-gdacs==0.8 aioairq==0.2.4 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.2.1 +aioairzone-cloud==0.2.3 # homeassistant.components.airzone aioairzone==0.6.8 @@ -209,13 +210,12 @@ aiobafi6==0.9.0 aiobotocore==2.6.0 # homeassistant.components.comelit -aiocomelit==0.0.8 +aiocomelit==0.0.9 # homeassistant.components.dhcp -aiodiscover==1.4.16 +aiodiscover==1.5.1 # homeassistant.components.dnsip -# homeassistant.components.minecraft_server aiodns==3.0.0 # homeassistant.components.eafm @@ -231,7 +231,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==16.0.5 +aioesphomeapi==17.0.1 # homeassistant.components.flo aioflo==2021.11.0 @@ -249,14 +249,14 @@ aioguardian==2022.07.0 aioharmony==0.2.10 # homeassistant.components.homekit_controller -aiohomekit==3.0.2 +aiohomekit==3.0.5 # homeassistant.components.emulated_hue # homeassistant.components.http -aiohttp-cors==0.7.0 +aiohttp_cors==0.7.0 # homeassistant.components.hue -aiohue==4.6.2 +aiohue==4.7.0 # homeassistant.components.imap aioimaplib==1.0.1 @@ -333,7 +333,7 @@ aiorecollect==2023.09.0 aioridwell==2023.07.0 # homeassistant.components.ruckus_unleashed -aioruckus==0.31 +aioruckus==0.34 # homeassistant.components.ruuvi_gateway aioruuvigateway==0.1.0 @@ -363,16 +363,16 @@ aiosyncthing==0.5.1 aiotractive==0.5.6 # homeassistant.components.unifi -aiounifi==62 +aiounifi==63 # homeassistant.components.vlc_telnet aiovlc==0.1.0 # homeassistant.components.vodafone_station -aiovodafone==0.2.0 +aiovodafone==0.3.1 # homeassistant.components.waqi -aiowaqi==0.2.1 +aiowaqi==2.0.0 # homeassistant.components.watttime aiowatttime==0.1.1 @@ -402,10 +402,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 +422,11 @@ anthemav==1.4.1 # homeassistant.components.apcupsd apcaccess==0.0.13 +# homeassistant.components.weatherkit +apple_weatherkit==1.0.4 + # homeassistant.components.apprise -apprise==1.4.5 +apprise==1.5.0 # homeassistant.components.aprs aprslib==0.7.0 @@ -444,7 +447,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 +458,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.36.1 # homeassistant.components.keyboard_remote asyncinotify==4.0.2 @@ -509,7 +512,7 @@ beautifulsoup4==4.12.2 # beewi-smartclim==0.0.10 # homeassistant.components.zha -bellows==0.36.4 +bellows==0.36.5 # homeassistant.components.bmw_connected_drive bimmer-connected==0.14.0 @@ -518,7 +521,7 @@ bimmer-connected==0.14.0 bizkaibus==0.1.1 # homeassistant.components.bluetooth -bleak-retry-connector==3.1.3 +bleak-retry-connector==3.2.1 # homeassistant.components.bluetooth bleak==0.21.1 @@ -549,7 +552,8 @@ bluetooth-auto-recovery==1.2.3 # homeassistant.components.esphome # homeassistant.components.ld2410_ble # homeassistant.components.led_ble -bluetooth-data-tools==1.11.0 +# homeassistant.components.private_ble_device +bluetooth-data-tools==1.12.0 # homeassistant.components.bond bond-async==0.2.1 @@ -577,7 +581,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 +645,10 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.bluetooth -dbus-fast==1.95.2 +dbus-fast==2.11.0 # homeassistant.components.debugpy -debugpy==1.6.7 +debugpy==1.8.0 # homeassistant.components.decora_wifi # decora-wifi==1.4 @@ -664,7 +668,7 @@ deluge-client==1.7.1 demetriek==0.4.0 # homeassistant.components.denonavr -denonavr==0.11.3 +denonavr==0.11.4 # homeassistant.components.devolo_home_control devolo-home-control-api==0.18.2 @@ -724,7 +728,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 @@ -772,7 +776,7 @@ eufylife-ble-client==0.1.7 evohome-async==0.3.15 # homeassistant.components.faa_delays -faadelays==0.0.7 +faadelays==2023.9.1 # homeassistant.components.dlib_face_detect # homeassistant.components.dlib_face_identify @@ -806,14 +810,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 @@ -835,7 +839,7 @@ fritzconnection[qr]==1.13.2 gTTS==2.2.4 # homeassistant.components.gardena_bluetooth -gardena_bluetooth==1.4.0 +gardena-bluetooth==1.4.0 # homeassistant.components.google_assistant_sdk gassist-text==0.0.10 @@ -863,7 +867,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 @@ -897,7 +900,7 @@ google-cloud-texttospeech==2.12.3 google-generativeai==0.1.0 # homeassistant.components.nest -google-nest-sdm==2.2.5 +google-nest-sdm==3.0.2 # homeassistant.components.google_travel_time googlemaps==2.5.1 @@ -918,7 +921,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 @@ -994,16 +997,16 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20230911.0 +home-assistant-frontend==20231002.0 # homeassistant.components.conversation -home-assistant-intents==2023.9.22 +home-assistant-intents==2023.10.2 # homeassistant.components.home_connect 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 +1041,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 +1066,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 +1075,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 +1087,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 @@ -1120,7 +1126,7 @@ laundrify-aio==1.1.2 ld2410-ble==0.1.1 # homeassistant.components.led_ble -led-ble==1.0.0 +led-ble==1.0.1 # homeassistant.components.foscam libpyfoscam==1.0 @@ -1177,7 +1183,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 @@ -1191,6 +1197,9 @@ mcstatus==11.0.0 # homeassistant.components.meater meater-python==0.0.8 +# homeassistant.components.medcom_ble +medcom-ble==0.1.1 + # homeassistant.components.melnor melnor-bluetooth==0.0.25 @@ -1210,10 +1219,10 @@ mficlient==0.3.0 micloud==0.5 # homeassistant.components.mill -mill-local==0.2.0 +mill-local==0.3.0 # homeassistant.components.mill -millheater==0.11.5 +millheater==0.11.6 # homeassistant.components.minio minio==7.1.12 @@ -1237,7 +1246,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 +1320,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 +1383,7 @@ openwrt-luci-rpc==1.1.16 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.0.33 +opower==0.0.35 # homeassistant.components.oralb oralb-ble==0.17.6 @@ -1438,7 +1447,7 @@ plexauth==0.0.6 plexwebsocket==0.0.13 # homeassistant.components.plugwise -plugwise==0.32.2 +plugwise==0.33.0 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 @@ -1481,7 +1490,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 @@ -1493,7 +1502,7 @@ py-canary==0.5.3 py-cpuinfo==8.0.0 # homeassistant.components.dormakaba_dkey -py-dormakaba-dkey==1.0.4 +py-dormakaba-dkey==1.0.5 # homeassistant.components.melissa py-melissa-climate==2.1.4 @@ -1528,6 +1537,9 @@ pyCEC==0.5.2 # homeassistant.components.control4 pyControl4==1.1.0 +# homeassistant.components.duotecno +pyDuotecno==2023.9.0 + # homeassistant.components.eight_sleep pyEight==0.3.2 @@ -1553,7 +1565,7 @@ pyTibber==0.28.2 pyW215==0.7.0 # homeassistant.components.w800rf32 -pyW800rf32==0.1 +pyW800rf32==0.4 # homeassistant.components.ads pyads==3.2.2 @@ -1655,12 +1667,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 @@ -1746,13 +1758,13 @@ 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 # homeassistant.components.ipma -pyipma==3.0.6 +pyipma==3.0.7 # homeassistant.components.ipp pyipp==0.14.4 @@ -1818,7 +1830,7 @@ pylibrespot-java==0.1.1 pylitejet==0.5.0 # homeassistant.components.litterrobot -pylitterbot==2023.4.5 +pylitterbot==2023.4.9 # homeassistant.components.lutron_caseta pylutron-caseta==0.18.2 @@ -1859,9 +1871,6 @@ pymonoprice==0.4 # homeassistant.components.msteams pymsteams==0.1.12 -# homeassistant.components.myq -pymyq==3.1.4 - # homeassistant.components.mysensors pymysensors==0.24.0 @@ -1991,7 +2000,10 @@ pysaj==0.0.16 pyschlage==2023.9.1 # homeassistant.components.sensibo -pysensibo==1.0.33 +pysensibo==1.0.35 + +# homeassistant.components.zha +pyserial-asyncio-fast==0.11 # homeassistant.components.serial # homeassistant.components.zha @@ -2107,7 +2119,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 @@ -2136,6 +2148,9 @@ python-miio==0.5.12 # homeassistant.components.mpd python-mpd2==3.0.5 +# homeassistant.components.myq +python-myq==3.1.11 + # homeassistant.components.mystrom python-mystrom==2.2.0 @@ -2159,7 +2174,7 @@ python-qbittorrent==0.4.3 python-ripple-api==0.0.3 # homeassistant.components.roborock -python-roborock==0.34.1 +python-roborock==0.34.6 # homeassistant.components.smarttub python-smarttub==0.0.33 @@ -2198,13 +2213,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 @@ -2233,6 +2248,9 @@ pyvolumio==0.1.5 # homeassistant.components.waze_travel_time pywaze==0.5.0 +# homeassistant.components.weatherflow +pyweatherflowudp==1.4.3 + # homeassistant.components.html5 pywebpush==1.9.2 @@ -2291,7 +2309,7 @@ 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.10 @@ -2357,7 +2375,7 @@ satel-integra==0.3.7 scapy==2.5.0 # homeassistant.components.screenlogic -screenlogicpy==0.8.2 +screenlogicpy==0.9.1 # homeassistant.components.scsgate scsgate==0.1.0 @@ -2368,11 +2386,9 @@ securetar==2023.3.0 # homeassistant.components.sendgrid sendgrid==6.8.2 -# homeassistant.components.sense -sense-energy==0.12.1 - # homeassistant.components.emulated_kasa -sense_energy==0.12.1 +# homeassistant.components.sense +sense-energy==0.12.2 # homeassistant.components.sensirion_ble sensirion-ble==0.1.1 @@ -2384,7 +2400,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 +2409,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 +2418,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,6 +2516,9 @@ 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 @@ -2579,7 +2598,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 @@ -2597,7 +2616,7 @@ twentemilieu==1.0.0 twilio==6.32.0 # homeassistant.components.twitch -twitchAPI==3.10.0 +twitchAPI==4.0.0 # homeassistant.components.ukraine_alarm uasiren==0.0.1 @@ -2681,7 +2700,7 @@ waterfurnace==1.1.0 webexteamssdk==1.1.1 # homeassistant.components.assist_pipeline -webrtcvad==2.0.10 +webrtc-noise-gain==1.2.3 # homeassistant.components.whirlpool whirlpool-sixth-sense==0.18.4 @@ -2705,7 +2724,7 @@ wled==0.16.0 wolf-smartset==0.1.11 # homeassistant.components.wyoming -wyoming==1.1.0 +wyoming==1.2.0 # homeassistant.components.xbox xbox-webapi==2.0.11 @@ -2717,12 +2736,11 @@ xiaomi-ble==0.21.1 xknx==2.11.2 # homeassistant.components.knx -xknxproject==3.2.0 +xknxproject==3.3.0 # homeassistant.components.bluesound # homeassistant.components.fritz # homeassistant.components.rest -# homeassistant.components.ruckus_unleashed # homeassistant.components.startca # homeassistant.components.ted5000 # homeassistant.components.zestimate @@ -2739,7 +2757,7 @@ yalesmartalarmclient==0.3.9 yalexs-ble==2.3.0 # homeassistant.components.august -yalexs==1.9.0 +yalexs==1.10.0 # homeassistant.components.yeelight yeelight==0.7.13 @@ -2757,7 +2775,7 @@ youless-api==1.0.1 youtubeaio==1.1.5 # homeassistant.components.media_extractor -yt-dlp==2023.7.6 +yt-dlp==2023.9.24 # homeassistant.components.zamg zamg==0.3.0 @@ -2766,13 +2784,13 @@ zamg==0.3.0 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.98.0 +zeroconf==0.115.1 # homeassistant.components.zeversolar zeversolar==0.3.1 # homeassistant.components.zha -zha-quirks==0.0.103 +zha-quirks==0.0.104 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.9 @@ -2784,22 +2802,22 @@ ziggo-mediabox-xl==1.1.0 zigpy-deconz==0.21.1 # homeassistant.components.zha -zigpy-xbee==0.18.2 +zigpy-xbee==0.18.3 # homeassistant.components.zha zigpy-zigate==0.11.0 # homeassistant.components.zha -zigpy-znp==0.11.4 +zigpy-znp==0.11.5 # homeassistant.components.zha -zigpy==0.57.1 +zigpy==0.57.2 # homeassistant.components.zoneminder zm-py==0.5.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.51.3 +zwave-js-server-python==0.52.1 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/requirements_test.txt b/requirements_test.txt index a2533d0ef2b..15404c159b9 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -7,18 +7,18 @@ -c homeassistant/package_constraints.txt -r requirements_test_pre_commit.txt -astroid==2.15.4 -coverage==7.3.0 +astroid==2.15.7 +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==2.17.6 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,26 +29,24 @@ 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==23.2.0.0 types-atomicwrites==1.4.5.1 types-croniter==1.0.6 -types-backports==0.1.3 types-beautifulsoup4==4.12.0.6 types-caldav==1.3.0.0 types-chardet==0.1.5 -types-decorator==5.1.8.3 -types-enum34==1.1.8 -types-ipaddress==1.0.8 -types-paho-mqtt==1.6.0.6 -types-Pillow==10.0.0.2 -types-pkg-resources==0.1.3 -types-psutil==5.9.5 -types-python-dateutil==2.8.19.13 +types-decorator==5.1.8.4 +types-paho-mqtt==1.6.0.7 +types-Pillow==10.0.0.3 +types-protobuf==4.24.0.2 +types-psutil==5.9.5.16 +types-python-dateutil==2.8.19.14 types-python-slugify==0.1.2 -types-pytz==2023.3.0.0 -types-PyYAML==6.0.12.2 -types-requests==2.31.0.1 +types-pytz==2023.3.1.1 +types-PyYAML==6.0.12.12 +types-requests==2.31.0.3 types-xmltodict==0.13.0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e1c571d6472..083595f13aa 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -4,10 +4,10 @@ -r requirements_test.txt # homeassistant.components.aemet -AEMET-OpenData==0.4.4 +AEMET-OpenData==0.4.5 # homeassistant.components.aladdin_connect -AIOAladdinConnect==0.1.57 +AIOAladdinConnect==0.1.58 # homeassistant.components.honeywell AIOSomecomfort==0.0.17 @@ -33,18 +33,19 @@ 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 # homeassistant.components.sighthound # homeassistant.components.tensorflow -Pillow==10.0.0 +Pillow==10.0.1 # homeassistant.components.plex -PlexAPI==4.13.2 +PlexAPI==4.15.3 # homeassistant.components.progettihwsw -ProgettiHWSW==0.1.1 +ProgettiHWSW==0.1.3 # homeassistant.components.cast PyChromecast==13.0.7 @@ -86,7 +87,7 @@ PyRMVtransport==0.3.3 PySocks==1.7.1 # homeassistant.components.switchbot -PySwitchbot==0.39.1 +PySwitchbot==0.40.1 # homeassistant.components.syncthru PySyncThru==0.7.10 @@ -115,7 +116,7 @@ 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 @@ -166,7 +167,7 @@ aio-georss-gdacs==0.8 aioairq==0.2.4 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.2.1 +aioairzone-cloud==0.2.3 # homeassistant.components.airzone aioairzone==0.6.8 @@ -190,13 +191,12 @@ aiobafi6==0.9.0 aiobotocore==2.6.0 # homeassistant.components.comelit -aiocomelit==0.0.8 +aiocomelit==0.0.9 # homeassistant.components.dhcp -aiodiscover==1.4.16 +aiodiscover==1.5.1 # homeassistant.components.dnsip -# homeassistant.components.minecraft_server aiodns==3.0.0 # homeassistant.components.eafm @@ -212,7 +212,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==16.0.5 +aioesphomeapi==17.0.1 # homeassistant.components.flo aioflo==2021.11.0 @@ -227,14 +227,14 @@ aioguardian==2022.07.0 aioharmony==0.2.10 # homeassistant.components.homekit_controller -aiohomekit==3.0.2 +aiohomekit==3.0.5 # homeassistant.components.emulated_hue # homeassistant.components.http -aiohttp-cors==0.7.0 +aiohttp_cors==0.7.0 # homeassistant.components.hue -aiohue==4.6.2 +aiohue==4.7.0 # homeassistant.components.imap aioimaplib==1.0.1 @@ -308,7 +308,7 @@ aiorecollect==2023.09.0 aioridwell==2023.07.0 # homeassistant.components.ruckus_unleashed -aioruckus==0.31 +aioruckus==0.34 # homeassistant.components.ruuvi_gateway aioruuvigateway==0.1.0 @@ -338,13 +338,16 @@ aiosyncthing==0.5.1 aiotractive==0.5.6 # homeassistant.components.unifi -aiounifi==62 +aiounifi==63 # homeassistant.components.vlc_telnet aiovlc==0.1.0 # homeassistant.components.vodafone_station -aiovodafone==0.2.0 +aiovodafone==0.3.1 + +# homeassistant.components.waqi +aiowaqi==2.0.0 # homeassistant.components.watttime aiowatttime==0.1.1 @@ -371,7 +374,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 +388,11 @@ anthemav==1.4.1 # homeassistant.components.apcupsd apcaccess==0.0.13 +# homeassistant.components.weatherkit +apple_weatherkit==1.0.4 + # homeassistant.components.apprise -apprise==1.4.5 +apprise==1.5.0 # homeassistant.components.aprs aprslib==0.7.0 @@ -397,16 +403,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.36.1 # homeassistant.components.sleepiq asyncsleepiq==1.3.7 @@ -430,13 +436,13 @@ base36==0.1.1 beautifulsoup4==4.12.2 # homeassistant.components.zha -bellows==0.36.4 +bellows==0.36.5 # homeassistant.components.bmw_connected_drive bimmer-connected==0.14.0 # homeassistant.components.bluetooth -bleak-retry-connector==3.1.3 +bleak-retry-connector==3.2.1 # homeassistant.components.bluetooth bleak==0.21.1 @@ -460,7 +466,8 @@ bluetooth-auto-recovery==1.2.3 # homeassistant.components.esphome # homeassistant.components.ld2410_ble # homeassistant.components.led_ble -bluetooth-data-tools==1.11.0 +# homeassistant.components.private_ble_device +bluetooth-data-tools==1.12.0 # homeassistant.components.bond bond-async==0.2.1 @@ -481,7 +488,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 +528,10 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.bluetooth -dbus-fast==1.95.2 +dbus-fast==2.11.0 # homeassistant.components.debugpy -debugpy==1.6.7 +debugpy==1.8.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns @@ -538,7 +545,7 @@ deluge-client==1.7.1 demetriek==0.4.0 # homeassistant.components.denonavr -denonavr==0.11.3 +denonavr==0.11.4 # homeassistant.components.devolo_home_control devolo-home-control-api==0.18.2 @@ -580,7 +587,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 @@ -613,7 +620,7 @@ esphome-dashboard-api==1.2.3 eufylife-ble-client==0.1.7 # homeassistant.components.faa_delays -faadelays==0.0.7 +faadelays==2023.9.1 # homeassistant.components.feedreader feedparser==6.0.10 @@ -621,6 +628,9 @@ feedparser==6.0.10 # homeassistant.components.file file-read-backwards==2.0.0 +# homeassistant.components.fitbit +fitbit==0.3.1 + # homeassistant.components.fivem fivem-api==0.1.2 @@ -631,14 +641,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 @@ -654,7 +664,7 @@ fritzconnection[qr]==1.13.2 gTTS==2.2.4 # homeassistant.components.gardena_bluetooth -gardena_bluetooth==1.4.0 +gardena-bluetooth==1.4.0 # homeassistant.components.google_assistant_sdk gassist-text==0.0.10 @@ -679,7 +689,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 @@ -707,7 +716,7 @@ google-cloud-pubsub==2.13.11 google-generativeai==0.1.0 # homeassistant.components.nest -google-nest-sdm==2.2.5 +google-nest-sdm==3.0.2 # homeassistant.components.google_travel_time googlemaps==2.5.1 @@ -719,7 +728,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 @@ -777,16 +786,16 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20230911.0 +home-assistant-frontend==20231002.0 # homeassistant.components.conversation -home-assistant-intents==2023.9.22 +home-assistant-intents==2023.10.2 # homeassistant.components.home_connect 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 +821,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 +837,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 +855,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 @@ -870,7 +882,7 @@ laundrify-aio==1.1.2 ld2410-ble==0.1.1 # homeassistant.components.led_ble -led-ble==1.0.0 +led-ble==1.0.1 # homeassistant.components.foscam libpyfoscam==1.0 @@ -887,6 +899,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 +914,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 @@ -911,6 +929,9 @@ mcstatus==11.0.0 # homeassistant.components.meater meater-python==0.0.8 +# homeassistant.components.medcom_ble +medcom-ble==0.1.1 + # homeassistant.components.melnor melnor-bluetooth==0.0.25 @@ -924,10 +945,10 @@ mficlient==0.3.0 micloud==0.5 # homeassistant.components.mill -mill-local==0.2.0 +mill-local==0.3.0 # homeassistant.components.mill -millheater==0.11.5 +millheater==0.11.6 # homeassistant.components.minio minio==7.1.12 @@ -951,7 +972,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 +1025,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 +1061,7 @@ openerz-api==0.2.0 openhomedevice==2.2.0 # homeassistant.components.opower -opower==0.0.33 +opower==0.0.35 # homeassistant.components.oralb oralb-ble==0.17.6 @@ -1086,7 +1107,7 @@ plexauth==0.0.6 plexwebsocket==0.0.13 # homeassistant.components.plugwise -plugwise==0.32.2 +plugwise==0.33.0 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 @@ -1114,7 +1135,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 @@ -1126,7 +1147,7 @@ py-canary==0.5.3 py-cpuinfo==8.0.0 # homeassistant.components.dormakaba_dkey -py-dormakaba-dkey==1.0.4 +py-dormakaba-dkey==1.0.5 # homeassistant.components.melissa py-melissa-climate==2.1.4 @@ -1149,6 +1170,9 @@ pyCEC==0.5.2 # homeassistant.components.control4 pyControl4==1.1.0 +# homeassistant.components.duotecno +pyDuotecno==2023.9.0 + # homeassistant.components.eight_sleep pyEight==0.3.2 @@ -1167,6 +1191,9 @@ pyW215==0.7.0 # homeassistant.components.hisense_aehw4a1 pyaehw4a1==0.3.9 +# homeassistant.components.aftership +pyaftership==21.11.0 + # homeassistant.components.airnow pyairnow==1.2.1 @@ -1222,11 +1249,14 @@ pydexcom==0.2.3 # homeassistant.components.discovergy pydiscovergy==2.0.3 +# homeassistant.components.hydrawise +pydrawise==2023.8.0 + # 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 @@ -1292,10 +1322,10 @@ 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 +pyipma==3.0.7 # homeassistant.components.ipp pyipp==0.14.4 @@ -1346,7 +1376,7 @@ pylibrespot-java==0.1.1 pylitejet==0.5.0 # homeassistant.components.litterrobot -pylitterbot==2023.4.5 +pylitterbot==2023.4.9 # homeassistant.components.lutron_caseta pylutron-caseta==0.18.2 @@ -1375,9 +1405,6 @@ pymodbus==3.5.2 # homeassistant.components.monoprice pymonoprice==0.4 -# homeassistant.components.myq -pymyq==3.1.4 - # homeassistant.components.mysensors pymysensors==0.24.0 @@ -1480,7 +1507,10 @@ pysabnzbd==1.1.1 pyschlage==2023.9.1 # homeassistant.components.sensibo -pysensibo==1.0.33 +pysensibo==1.0.35 + +# homeassistant.components.zha +pyserial-asyncio-fast==0.11 # homeassistant.components.serial # homeassistant.components.zha @@ -1551,7 +1581,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 @@ -1568,6 +1598,9 @@ python-matter-server==3.7.0 # homeassistant.components.xiaomi_miio python-miio==0.5.12 +# homeassistant.components.myq +python-myq==3.1.11 + # homeassistant.components.mystrom python-mystrom==2.2.0 @@ -1585,7 +1618,7 @@ python-picnic-api==1.1.0 python-qbittorrent==0.4.3 # homeassistant.components.roborock -python-roborock==0.34.1 +python-roborock==0.34.6 # homeassistant.components.smarttub python-smarttub==0.0.33 @@ -1615,13 +1648,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 @@ -1641,6 +1674,9 @@ pyvolumio==0.1.5 # homeassistant.components.waze_travel_time pywaze==0.5.0 +# homeassistant.components.weatherflow +pyweatherflowudp==1.4.3 + # homeassistant.components.html5 pywebpush==1.9.2 @@ -1656,6 +1692,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,7 +1720,7 @@ 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.10 @@ -1723,16 +1762,14 @@ samsungtvws[async,encrypted]==2.6.0 scapy==2.5.0 # homeassistant.components.screenlogic -screenlogicpy==0.8.2 +screenlogicpy==0.9.1 # homeassistant.components.backup securetar==2023.3.0 -# homeassistant.components.sense -sense-energy==0.12.1 - # homeassistant.components.emulated_kasa -sense_energy==0.12.1 +# homeassistant.components.sense +sense-energy==0.12.2 # homeassistant.components.sensirion_ble sensirion-ble==0.1.1 @@ -1744,7 +1781,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 +1793,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,6 +1873,9 @@ 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.8.2 @@ -1879,7 +1919,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 @@ -1897,7 +1937,7 @@ twentemilieu==1.0.0 twilio==6.32.0 # homeassistant.components.twitch -twitchAPI==3.10.0 +twitchAPI==4.0.0 # homeassistant.components.ukraine_alarm uasiren==0.0.1 @@ -1966,7 +2006,7 @@ wallbox==0.4.12 watchdog==2.3.1 # homeassistant.components.assist_pipeline -webrtcvad==2.0.10 +webrtc-noise-gain==1.2.3 # homeassistant.components.whirlpool whirlpool-sixth-sense==0.18.4 @@ -1987,7 +2027,7 @@ wled==0.16.0 wolf-smartset==0.1.11 # homeassistant.components.wyoming -wyoming==1.1.0 +wyoming==1.2.0 # homeassistant.components.xbox xbox-webapi==2.0.11 @@ -1999,12 +2039,11 @@ xiaomi-ble==0.21.1 xknx==2.11.2 # homeassistant.components.knx -xknxproject==3.2.0 +xknxproject==3.3.0 # homeassistant.components.bluesound # homeassistant.components.fritz # homeassistant.components.rest -# homeassistant.components.ruckus_unleashed # homeassistant.components.startca # homeassistant.components.ted5000 # homeassistant.components.zestimate @@ -2018,7 +2057,7 @@ yalesmartalarmclient==0.3.9 yalexs-ble==2.3.0 # homeassistant.components.august -yalexs==1.9.0 +yalexs==1.10.0 # homeassistant.components.yeelight yeelight==0.7.13 @@ -2032,35 +2071,38 @@ youless-api==1.0.1 # homeassistant.components.youtube youtubeaio==1.1.5 +# homeassistant.components.media_extractor +yt-dlp==2023.9.24 + # homeassistant.components.zamg zamg==0.3.0 # homeassistant.components.zeroconf -zeroconf==0.98.0 +zeroconf==0.115.1 # homeassistant.components.zeversolar zeversolar==0.3.1 # homeassistant.components.zha -zha-quirks==0.0.103 +zha-quirks==0.0.104 # homeassistant.components.zha zigpy-deconz==0.21.1 # homeassistant.components.zha -zigpy-xbee==0.18.2 +zigpy-xbee==0.18.3 # homeassistant.components.zha zigpy-zigate==0.11.0 # homeassistant.components.zha -zigpy-znp==0.11.4 +zigpy-znp==0.11.5 # homeassistant.components.zha -zigpy==0.57.1 +zigpy==0.57.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.51.3 +zwave-js-server-python==0.52.1 # 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 844d796e7af..dadc3e0cab2 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/rootfs/etc/services.d/home-assistant/finish b/rootfs/etc/services.d/home-assistant/finish index 057957a9c03..ae5b17e171a 100755 --- a/rootfs/etc/services.d/home-assistant/finish +++ b/rootfs/etc/services.d/home-assistant/finish @@ -18,13 +18,11 @@ elif [[ ${APP_EXIT_CODE} -eq ${SIGNAL_EXIT_CODE} ]]; then NEW_EXIT_CODE=$((128 + SIGNAL_NO)) echo ${NEW_EXIT_CODE} > /run/s6-linux-init-container-results/exitcode - - if [[ ${SIGNAL_NO} -eq ${SIGTERM} ]]; then - /run/s6/basedir/bin/halt - fi else bashio::log.info "Home Assistant Core service shutdown" echo ${APP_EXIT_CODE} > /run/s6-linux-init-container-results/exitcode - /run/s6/basedir/bin/halt fi + +# Make sure to stop the container +/run/s6/basedir/bin/halt diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 101a57e419d..e27b681f998 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -4,6 +4,7 @@ from __future__ import annotations import difflib import importlib +from operator import itemgetter import os from pathlib import Path import pkgutil @@ -71,9 +72,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 +113,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 @@ -127,8 +128,9 @@ multidict>=6.0.2 # Version 2.0 added typing, prevent accidental fallbacks backoff>=2.0 -# Require to avoid issues with decorators (#93904). v2 has breaking changes. -pydantic>=1.10.8,<2.0 +# Required to avoid breaking (#101042). +# v2 has breaking changes (#99218). +pydantic==1.10.12 # Breaks asyncio # https://github.com/pubnub/python/issues/130 @@ -148,7 +150,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 @@ -173,6 +175,11 @@ pysnmp==1000000000.0.0 # The get-mac package has been replaced with getmac. Installing get-mac alongside getmac # breaks getmac due to them both sharing the same python package name inside 'getmac'. get-mac==1000000000.0.0 + +# We want to skip the binary wheels for the 'charset-normalizer' packages. +# They are build with mypyc, but causes issues with our wheel builder. +# In order to do so, we need to constrain the version. +charset-normalizer==3.2.0 """ IGNORE_PRE_COMMIT_HOOK_ID = ( @@ -333,7 +340,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 +432,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 1c626ac3c5b..32803731ecd 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/translations/const.py b/script/translations/const.py index 7c50b7db5e3..ef8e3f2df74 100644 --- a/script/translations/const.py +++ b/script/translations/const.py @@ -3,6 +3,6 @@ import pathlib CORE_PROJECT_ID = "130246255a974bd3b5e8a1.51616605" FRONTEND_PROJECT_ID = "3420425759f6d6d241f598.13594006" -CLI_2_DOCKER_IMAGE = "2.5.1" +CLI_2_DOCKER_IMAGE = "v2.6.8" INTEGRATIONS_DIR = pathlib.Path("homeassistant/components") FRONTEND_DIR = pathlib.Path("../frontend") diff --git a/script/version_bump.py b/script/version_bump.py index 4c4f8a97f09..5e383ab7d4b 100755 --- a/script/version_bump.py +++ b/script/version_bump.py @@ -135,8 +135,8 @@ def write_ci_workflow(version: Version) -> None: short_version = ".".join(str(version).split(".", maxsplit=2)[:2]) content = re.sub( - r"(\n\W+HA_SHORT_VERSION: )\d{4}\.\d{1,2}\n", - f"\\g<1>{short_version}\n", + r"(\n\W+HA_SHORT_VERSION: )\"\d{4}\.\d{1,2}\"\n", + f'\\g<1>"{short_version}"\n', content, count=1, ) diff --git a/tests/common.py b/tests/common.py index 48bb38383c7..cd522aa3320 100644 --- a/tests/common.py +++ b/tests/common.py @@ -67,7 +67,10 @@ from homeassistant.helpers import ( restore_state as rs, storage, ) -from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) from homeassistant.helpers.json import JSONEncoder, _orjson_default_encoder from homeassistant.helpers.typing import ConfigType, StateType from homeassistant.setup import setup_component @@ -891,7 +894,7 @@ class MockConfigEntry(config_entries.ConfigEntry): 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 +916,15 @@ class MockConfigEntry(config_entries.ConfigEntry): 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): @@ -1445,3 +1446,17 @@ def async_get_persistent_notifications( ) -> dict[str, pn.Notification]: """Get the current persistent notifications.""" return pn._async_get_or_create_notifications(hass) + + +def async_mock_cloud_connection_status(hass: HomeAssistant, connected: bool) -> None: + """Mock a signal the cloud disconnected.""" + from homeassistant.components.cloud import ( + SIGNAL_CLOUD_CONNECTION_STATE, + CloudConnectionState, + ) + + if connected: + state = CloudConnectionState.CLOUD_CONNECTED + else: + state = CloudConnectionState.CLOUD_DISCONNECTED + async_dispatcher_send(hass, SIGNAL_CLOUD_CONNECTION_STATE, state) diff --git a/tests/components/aemet/test_sensor.py b/tests/components/aemet/test_sensor.py index 4d61dde34fc..7b6f02f8b06 100644 --- a/tests/components/aemet/test_sensor.py +++ b/tests/components/aemet/test_sensor.py @@ -6,7 +6,6 @@ from homeassistant.components.weather import ( 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 ddcc29698fd..d0042faaaa0 100644 --- a/tests/components/aemet/test_weather.py +++ b/tests/components/aemet/test_weather.py @@ -26,6 +26,7 @@ from homeassistant.components.weather import ( 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/aftership/__init__.py b/tests/components/aftership/__init__.py new file mode 100644 index 00000000000..cdc39e5edfc --- /dev/null +++ b/tests/components/aftership/__init__.py @@ -0,0 +1 @@ +"""Tests for the AfterShip integration.""" diff --git a/tests/components/aftership/conftest.py b/tests/components/aftership/conftest.py new file mode 100644 index 00000000000..e3fdc00bc30 --- /dev/null +++ b/tests/components/aftership/conftest.py @@ -0,0 +1,14 @@ +"""Common fixtures for the AfterShip 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.aftership.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/aftership/test_config_flow.py b/tests/components/aftership/test_config_flow.py new file mode 100644 index 00000000000..2ac5919a555 --- /dev/null +++ b/tests/components/aftership/test_config_flow.py @@ -0,0 +1,110 @@ +"""Test AfterShip config flow.""" +from unittest.mock import AsyncMock, patch + +from pyaftership import AfterShipException + +from homeassistant.components.aftership.const import DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import issue_registry as ir + +from tests.common import MockConfigEntry + + +async def test_full_user_flow(hass: HomeAssistant, mock_setup_entry) -> None: + """Test the full user configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + with patch( + "homeassistant.components.aftership.config_flow.AfterShip", + return_value=AsyncMock(), + ) as mock_aftership: + mock_aftership.return_value.trackings.return_value.list.return_value = {} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_API_KEY: "mock-api-key", + }, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "AfterShip" + assert result["data"] == { + CONF_API_KEY: "mock-api-key", + } + + +async def test_flow_cannot_connect(hass: HomeAssistant, mock_setup_entry) -> None: + """Test handling invalid connection.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + with patch( + "homeassistant.components.aftership.config_flow.AfterShip", + return_value=AsyncMock(), + ) as mock_aftership: + mock_aftership.side_effect = AfterShipException + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_API_KEY: "mock-api-key", + }, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + with patch( + "homeassistant.components.aftership.config_flow.AfterShip", + return_value=AsyncMock(), + ) as mock_aftership: + mock_aftership.return_value.trackings.return_value.list.return_value = {} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_API_KEY: "mock-api-key", + }, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "AfterShip" + assert result["data"] == { + CONF_API_KEY: "mock-api-key", + } + + +async def test_import_flow(hass: HomeAssistant, mock_setup_entry) -> None: + """Test importing yaml config.""" + + with patch( + "homeassistant.components.aftership.config_flow.AfterShip", + return_value=AsyncMock(), + ) as mock_aftership: + mock_aftership.return_value.trackings.return_value.list.return_value = {} + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_API_KEY: "yaml-api-key"}, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "AfterShip" + assert result["data"] == { + CONF_API_KEY: "yaml-api-key", + } + issue_registry = ir.async_get(hass) + assert len(issue_registry.issues) == 1 + + +async def test_import_flow_already_exists(hass: HomeAssistant) -> None: + """Test importing yaml config where entry already exists.""" + entry = MockConfigEntry(domain=DOMAIN, data={CONF_API_KEY: "yaml-api-key"}) + entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data={CONF_API_KEY: "yaml-api-key"} + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/airly/test_init.py b/tests/components/airly/test_init.py index 9b69607e6aa..0a3ea927446 100644 --- a/tests/components/airly/test_init.py +++ b/tests/components/airly/test_init.py @@ -5,8 +5,8 @@ from freezegun.api import FrozenDateTimeFactory 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/airzone/test_water_heater.py b/tests/components/airzone/test_water_heater.py new file mode 100644 index 00000000000..a1157192f23 --- /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/airzone_cloud/snapshots/test_diagnostics.ambr b/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr index 94e602ec03b..44bd0e45e2a 100644 --- a/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr +++ b/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr @@ -79,11 +79,29 @@ 'id': 'aidoo1', 'installation': 'installation1', 'is-connected': True, - 'mode': None, + 'mode': 3, + 'modes': list([ + 1, + 2, + 3, + 4, + 5, + ]), 'name': 'Bron', - 'power': None, + 'power': False, 'problems': False, 'temperature': 21.0, + 'temperature-setpoint': 22.0, + 'temperature-setpoint-cool-air': 22.0, + 'temperature-setpoint-hot-air': 22.0, + 'temperature-setpoint-max': 30.0, + 'temperature-setpoint-max-auto-air': 30.0, + 'temperature-setpoint-max-cool-air': 30.0, + 'temperature-setpoint-max-hot-air': 30.0, + 'temperature-setpoint-min': 15.0, + 'temperature-setpoint-min-auto-air': 18.0, + 'temperature-setpoint-min-cool-air': 18.0, + 'temperature-setpoint-min-hot-air': 16.0, 'temperature-step': 0.5, 'web-server': '11:22:33:44:55:67', 'ws-connected': True, @@ -91,19 +109,29 @@ }), 'groups': dict({ 'group1': dict({ - 'action': 6, + 'action': 1, 'active': True, 'available': True, 'humidity': 27, + 'id': 'group1', 'installation': 'installation1', - 'mode': 0, + 'mode': 2, + 'modes': list([ + 2, + 3, + 4, + 5, + ]), 'name': 'Group', 'num-devices': 2, - 'power': None, + 'power': True, 'systems': list([ 'system1', ]), 'temperature': 22.5, + 'temperature-setpoint': 24.0, + 'temperature-setpoint-max': 30.0, + 'temperature-setpoint-min': 15.0, 'temperature-step': 0.5, 'zones': list([ 'zone1', @@ -117,23 +145,63 @@ 'aidoo1', ]), 'available': True, + 'id': 'grp2', 'installation': 'installation1', - 'mode': 0, + 'mode': 3, + 'modes': list([ + 1, + 2, + 3, + 4, + 5, + ]), 'name': 'Aidoo Group', 'num-devices': 1, - 'power': None, + 'power': False, 'temperature': 21.0, + 'temperature-setpoint': 22.0, + 'temperature-setpoint-max': 30.0, + 'temperature-setpoint-min': 15.0, 'temperature-step': 0.5, }), }), 'installations': dict({ 'installation1': dict({ + 'action': 1, + 'active': True, + 'aidoos': list([ + 'aidoo1', + ]), + 'available': True, + 'humidity': 27, 'id': 'installation1', + 'mode': 2, + 'modes': list([ + 1, + 2, + 3, + 4, + 5, + ]), 'name': 'House', + 'num-devices': 3, + 'power': True, + 'systems': list([ + 'system1', + ]), + 'temperature': 22.0, + 'temperature-setpoint': 23.3, + 'temperature-setpoint-max': 30.0, + 'temperature-setpoint-min': 15.0, + 'temperature-step': 0.5, 'web-servers': list([ 'webserver1', '11:22:33:44:55:67', ]), + 'zones': list([ + 'zone1', + 'zone2', + ]), }), }), 'systems': dict({ @@ -147,7 +215,13 @@ 'id': 'system1', 'installation': 'installation1', 'is-connected': True, - 'mode': None, + 'mode': 2, + 'modes': list([ + 2, + 3, + 4, + 5, + ]), 'name': 'System 1', 'problems': True, 'system': 1, @@ -189,21 +263,47 @@ }), 'zones': dict({ 'zone1': dict({ - 'action': 6, + 'action': 1, 'active': True, 'available': True, 'humidity': 30, 'id': 'zone1', 'installation': 'installation1', 'is-connected': True, - 'master': None, - 'mode': None, + 'master': True, + 'mode': 2, + 'modes': list([ + 2, + 3, + 4, + 5, + ]), 'name': 'Salon', - 'power': None, + 'power': True, 'problems': False, 'system': 1, 'system-id': 'system1', 'temperature': 20.0, + 'temperature-setpoint': 24.0, + 'temperature-setpoint-cool-air': 24.0, + 'temperature-setpoint-dry-air': 24.0, + 'temperature-setpoint-hot-air': 20.0, + 'temperature-setpoint-max': 30.0, + 'temperature-setpoint-max-cool-air': 30.0, + 'temperature-setpoint-max-dry-air': 30.0, + 'temperature-setpoint-max-emerheat-air': 30.0, + 'temperature-setpoint-max-hot-air': 30.0, + 'temperature-setpoint-max-stop-air': 30.0, + 'temperature-setpoint-max-vent-air': 30.0, + 'temperature-setpoint-min': 15.0, + 'temperature-setpoint-min-cool-air': 18.0, + 'temperature-setpoint-min-dry-air': 18.0, + 'temperature-setpoint-min-emerheat-air': 15.0, + 'temperature-setpoint-min-hot-air': 15.0, + 'temperature-setpoint-min-stop-air': 15.0, + 'temperature-setpoint-min-vent-air': 15.0, + 'temperature-setpoint-stop-air': 24.0, + 'temperature-setpoint-vent-air': 24.0, 'temperature-step': 0.5, 'web-server': 'webserver1', 'ws-connected': True, @@ -217,14 +317,40 @@ 'id': 'zone2', 'installation': 'installation1', 'is-connected': True, - 'master': None, - 'mode': None, + 'master': False, + 'mode': 2, + 'modes': list([ + 2, + 3, + 4, + 5, + ]), 'name': 'Dormitorio', - 'power': None, + 'power': False, 'problems': False, 'system': 1, 'system-id': 'system1', 'temperature': 25.0, + 'temperature-setpoint': 24.0, + 'temperature-setpoint-cool-air': 24.0, + 'temperature-setpoint-dry-air': 24.0, + 'temperature-setpoint-hot-air': 20.0, + 'temperature-setpoint-max': 30.0, + 'temperature-setpoint-max-cool-air': 30.0, + 'temperature-setpoint-max-dry-air': 30.0, + 'temperature-setpoint-max-emerheat-air': 30.0, + 'temperature-setpoint-max-hot-air': 30.0, + 'temperature-setpoint-max-stop-air': 30.0, + 'temperature-setpoint-max-vent-air': 30.0, + 'temperature-setpoint-min': 15.0, + 'temperature-setpoint-min-cool-air': 18.0, + 'temperature-setpoint-min-dry-air': 18.0, + 'temperature-setpoint-min-emerheat-air': 15.0, + 'temperature-setpoint-min-hot-air': 15.0, + 'temperature-setpoint-min-stop-air': 15.0, + 'temperature-setpoint-min-vent-air': 15.0, + 'temperature-setpoint-stop-air': 24.0, + 'temperature-setpoint-vent-air': 24.0, 'temperature-step': 0.5, 'web-server': 'webserver1', 'ws-connected': True, diff --git a/tests/components/airzone_cloud/test_climate.py b/tests/components/airzone_cloud/test_climate.py new file mode 100644 index 00000000000..acf1d082c29 --- /dev/null +++ b/tests/components/airzone_cloud/test_climate.py @@ -0,0 +1,224 @@ +"""The climate tests for the Airzone Cloud platform.""" +from unittest.mock import patch + +from aioairzone_cloud.exceptions import AirzoneCloudError +import pytest + +from homeassistant.components.airzone.const import API_TEMPERATURE_STEP +from homeassistant.components.climate import ( + ATTR_CURRENT_HUMIDITY, + ATTR_CURRENT_TEMPERATURE, + ATTR_HVAC_ACTION, + ATTR_HVAC_MODE, + ATTR_HVAC_MODES, + ATTR_MAX_TEMP, + ATTR_MIN_TEMP, + ATTR_TARGET_TEMP_STEP, + DOMAIN as CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_TEMPERATURE, + HVACAction, + HVACMode, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_TEMPERATURE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from .util import async_init_integration + + +async def test_airzone_create_climates(hass: HomeAssistant) -> None: + """Test creation of climates.""" + + await async_init_integration(hass) + + # Zones + state = hass.states.get("climate.dormitorio") + assert state.state == HVACMode.OFF + assert state.attributes.get(ATTR_CURRENT_HUMIDITY) == 24 + assert state.attributes.get(ATTR_CURRENT_TEMPERATURE) == 25.0 + assert state.attributes.get(ATTR_HVAC_ACTION) == HVACAction.OFF + assert state.attributes.get(ATTR_HVAC_MODES) == [ + HVACMode.COOL, + HVACMode.HEAT, + HVACMode.FAN_ONLY, + HVACMode.DRY, + HVACMode.OFF, + ] + assert state.attributes.get(ATTR_MAX_TEMP) == 30 + assert state.attributes.get(ATTR_MIN_TEMP) == 15 + assert state.attributes.get(ATTR_TARGET_TEMP_STEP) == API_TEMPERATURE_STEP + assert state.attributes.get(ATTR_TEMPERATURE) == 24.0 + + state = hass.states.get("climate.salon") + assert state.state == HVACMode.COOL + assert state.attributes.get(ATTR_CURRENT_HUMIDITY) == 30 + assert state.attributes.get(ATTR_CURRENT_TEMPERATURE) == 20.0 + assert state.attributes.get(ATTR_HVAC_ACTION) == HVACAction.COOLING + assert state.attributes.get(ATTR_HVAC_MODES) == [ + HVACMode.COOL, + HVACMode.HEAT, + HVACMode.FAN_ONLY, + HVACMode.DRY, + HVACMode.OFF, + ] + assert state.attributes.get(ATTR_MAX_TEMP) == 30 + assert state.attributes.get(ATTR_MIN_TEMP) == 15 + assert state.attributes.get(ATTR_TARGET_TEMP_STEP) == API_TEMPERATURE_STEP + assert state.attributes.get(ATTR_TEMPERATURE) == 24.0 + + +async def test_airzone_climate_turn_on_off(hass: HomeAssistant) -> None: + """Test turning on/off.""" + + await async_init_integration(hass) + + # Zones + with patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_patch_device", + return_value=None, + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "climate.dormitorio", + }, + blocking=True, + ) + + state = hass.states.get("climate.dormitorio") + assert state.state == HVACMode.COOL + + with patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_patch_device", + return_value=None, + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_OFF, + { + ATTR_ENTITY_ID: "climate.salon", + }, + blocking=True, + ) + + state = hass.states.get("climate.salon") + assert state.state == HVACMode.OFF + + +async def test_airzone_climate_set_hvac_mode(hass: HomeAssistant) -> None: + """Test setting the HVAC mode.""" + + await async_init_integration(hass) + + # Zones + with patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_patch_device", + return_value=None, + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + { + ATTR_ENTITY_ID: "climate.salon", + ATTR_HVAC_MODE: HVACMode.HEAT, + }, + blocking=True, + ) + + state = hass.states.get("climate.salon") + assert state.state == HVACMode.HEAT + + with patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_patch_device", + return_value=None, + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + { + ATTR_ENTITY_ID: "climate.salon", + ATTR_HVAC_MODE: HVACMode.OFF, + }, + blocking=True, + ) + + state = hass.states.get("climate.salon") + assert state.state == HVACMode.OFF + + +async def test_airzone_climate_set_hvac_slave_error(hass: HomeAssistant) -> None: + """Test setting the HVAC mode for a slave zone.""" + + await async_init_integration(hass) + + with patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_patch_device", + return_value=None, + ), pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + { + ATTR_ENTITY_ID: "climate.dormitorio", + ATTR_HVAC_MODE: HVACMode.HEAT, + }, + blocking=True, + ) + + state = hass.states.get("climate.dormitorio") + assert state.state == HVACMode.COOL + + +async def test_airzone_climate_set_temp(hass: HomeAssistant) -> None: + """Test setting the target temperature.""" + + await async_init_integration(hass) + + # Zones + with patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_patch_device", + return_value=None, + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: "climate.salon", + ATTR_TEMPERATURE: 20.5, + }, + blocking=True, + ) + + state = hass.states.get("climate.salon") + assert state.attributes.get(ATTR_TEMPERATURE) == 20.5 + + +async def test_airzone_climate_set_temp_error(hass: HomeAssistant) -> None: + """Test error when setting the target temperature.""" + + await async_init_integration(hass) + + # Zones + with patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_patch_device", + side_effect=AirzoneCloudError, + ), pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: "climate.salon", + ATTR_TEMPERATURE: 20.5, + }, + blocking=True, + ) + + state = hass.states.get("climate.salon") + assert state.attributes.get(ATTR_TEMPERATURE) == 24.0 diff --git a/tests/components/airzone_cloud/util.py b/tests/components/airzone_cloud/util.py index 8fd7da06853..412f0df1337 100644 --- a/tests/components/airzone_cloud/util.py +++ b/tests/components/airzone_cloud/util.py @@ -3,6 +3,7 @@ from typing import Any from unittest.mock import patch +from aioairzone_cloud.common import OperationMode from aioairzone_cloud.const import ( API_ACTIVE, API_AZ_AIDOO, @@ -24,8 +25,33 @@ from aioairzone_cloud.const import ( API_IS_CONNECTED, API_LOCAL_TEMP, API_META, + API_MODE, + API_MODE_AVAIL, API_NAME, API_OLD_ID, + API_POWER, + API_RANGE_MAX_AIR, + API_RANGE_MIN_AIR, + API_RANGE_SP_MAX_AUTO_AIR, + API_RANGE_SP_MAX_COOL_AIR, + API_RANGE_SP_MAX_DRY_AIR, + API_RANGE_SP_MAX_EMERHEAT_AIR, + API_RANGE_SP_MAX_HOT_AIR, + API_RANGE_SP_MAX_STOP_AIR, + API_RANGE_SP_MAX_VENT_AIR, + API_RANGE_SP_MIN_AUTO_AIR, + API_RANGE_SP_MIN_COOL_AIR, + API_RANGE_SP_MIN_DRY_AIR, + API_RANGE_SP_MIN_EMERHEAT_AIR, + API_RANGE_SP_MIN_HOT_AIR, + API_RANGE_SP_MIN_STOP_AIR, + API_RANGE_SP_MIN_VENT_AIR, + API_SP_AIR_AUTO, + API_SP_AIR_COOL, + API_SP_AIR_DRY, + API_SP_AIR_HEAT, + API_SP_AIR_STOP, + API_SP_AIR_VENT, API_STAT_AP_MAC, API_STAT_CHANNEL, API_STAT_QUALITY, @@ -166,12 +192,29 @@ def mock_get_device_status(device: Device) -> dict[str, Any]: return { API_ACTIVE: False, API_ERRORS: [], + API_MODE: OperationMode.HEATING.value, + API_MODE_AVAIL: [ + OperationMode.AUTO.value, + OperationMode.COOLING.value, + OperationMode.HEATING.value, + OperationMode.VENTILATION.value, + OperationMode.DRY.value, + ], + API_SP_AIR_AUTO: {API_CELSIUS: 22, API_FAH: 72}, + API_SP_AIR_COOL: {API_CELSIUS: 22, API_FAH: 72}, + API_SP_AIR_HEAT: {API_CELSIUS: 22, API_FAH: 72}, + API_RANGE_MAX_AIR: {API_CELSIUS: 30, API_FAH: 86}, + API_RANGE_SP_MAX_AUTO_AIR: {API_CELSIUS: 30, API_FAH: 86}, + API_RANGE_SP_MAX_COOL_AIR: {API_CELSIUS: 30, API_FAH: 86}, + API_RANGE_SP_MAX_HOT_AIR: {API_CELSIUS: 30, API_FAH: 86}, + API_RANGE_MIN_AIR: {API_CELSIUS: 15, API_FAH: 59}, + API_RANGE_SP_MIN_AUTO_AIR: {API_CELSIUS: 18, API_FAH: 64}, + API_RANGE_SP_MIN_COOL_AIR: {API_CELSIUS: 18, API_FAH: 64}, + API_RANGE_SP_MIN_HOT_AIR: {API_CELSIUS: 16, API_FAH: 61}, + API_POWER: False, API_IS_CONNECTED: True, API_WS_CONNECTED: True, - API_LOCAL_TEMP: { - API_CELSIUS: 21, - API_FAH: 70, - }, + API_LOCAL_TEMP: {API_CELSIUS: 21, API_FAH: 70}, API_WARNINGS: [], } if device.get_id() == "system1": @@ -181,6 +224,13 @@ def mock_get_device_status(device: Device) -> dict[str, Any]: API_OLD_ID: "error-id", }, ], + API_MODE: OperationMode.COOLING.value, + API_MODE_AVAIL: [ + OperationMode.COOLING.value, + OperationMode.HEATING.value, + OperationMode.VENTILATION.value, + OperationMode.DRY.value, + ], API_IS_CONNECTED: True, API_WS_CONNECTED: True, API_WARNINGS: [], @@ -189,24 +239,67 @@ def mock_get_device_status(device: Device) -> dict[str, Any]: return { API_ACTIVE: True, API_HUMIDITY: 30, + API_MODE: OperationMode.COOLING.value, + API_MODE_AVAIL: [ + OperationMode.COOLING.value, + OperationMode.HEATING.value, + OperationMode.VENTILATION.value, + OperationMode.DRY.value, + ], + API_RANGE_MAX_AIR: {API_CELSIUS: 30, API_FAH: 86}, + API_RANGE_SP_MAX_COOL_AIR: {API_FAH: 86, API_CELSIUS: 30}, + API_RANGE_SP_MAX_DRY_AIR: {API_FAH: 86, API_CELSIUS: 30}, + API_RANGE_SP_MAX_EMERHEAT_AIR: {API_CELSIUS: 30, API_FAH: 86}, + API_RANGE_SP_MAX_HOT_AIR: {API_CELSIUS: 30, API_FAH: 86}, + API_RANGE_SP_MAX_STOP_AIR: {API_FAH: 86, API_CELSIUS: 30}, + API_RANGE_SP_MAX_VENT_AIR: {API_FAH: 86, API_CELSIUS: 30}, + API_RANGE_MIN_AIR: {API_CELSIUS: 15, API_FAH: 59}, + API_RANGE_SP_MIN_COOL_AIR: {API_CELSIUS: 18, API_FAH: 64}, + API_RANGE_SP_MIN_DRY_AIR: {API_CELSIUS: 18, API_FAH: 64}, + API_RANGE_SP_MIN_EMERHEAT_AIR: {API_FAH: 59, API_CELSIUS: 15}, + API_RANGE_SP_MIN_HOT_AIR: {API_FAH: 59, API_CELSIUS: 15}, + API_RANGE_SP_MIN_STOP_AIR: {API_FAH: 59, API_CELSIUS: 15}, + API_RANGE_SP_MIN_VENT_AIR: {API_FAH: 59, API_CELSIUS: 15}, + API_SP_AIR_COOL: {API_CELSIUS: 24, API_FAH: 75}, + API_SP_AIR_DRY: {API_CELSIUS: 24, API_FAH: 75}, + API_SP_AIR_HEAT: {API_CELSIUS: 20, API_FAH: 68}, + API_SP_AIR_VENT: {API_CELSIUS: 24, API_FAH: 75}, + API_SP_AIR_STOP: {API_CELSIUS: 24, API_FAH: 75}, + API_POWER: True, API_IS_CONNECTED: True, API_WS_CONNECTED: True, - API_LOCAL_TEMP: { - API_FAH: 68, - API_CELSIUS: 20, - }, + API_LOCAL_TEMP: {API_FAH: 68, API_CELSIUS: 20}, API_WARNINGS: [], } if device.get_id() == "zone2": return { API_ACTIVE: False, API_HUMIDITY: 24, + API_MODE: OperationMode.COOLING.value, + API_MODE_AVAIL: [], + API_RANGE_MAX_AIR: {API_CELSIUS: 30, API_FAH: 86}, + API_RANGE_SP_MAX_COOL_AIR: {API_FAH: 86, API_CELSIUS: 30}, + API_RANGE_SP_MAX_DRY_AIR: {API_FAH: 86, API_CELSIUS: 30}, + API_RANGE_SP_MAX_EMERHEAT_AIR: {API_CELSIUS: 30, API_FAH: 86}, + API_RANGE_SP_MAX_HOT_AIR: {API_CELSIUS: 30, API_FAH: 86}, + API_RANGE_SP_MAX_STOP_AIR: {API_FAH: 86, API_CELSIUS: 30}, + API_RANGE_SP_MAX_VENT_AIR: {API_FAH: 86, API_CELSIUS: 30}, + API_RANGE_MIN_AIR: {API_CELSIUS: 15, API_FAH: 59}, + API_RANGE_SP_MIN_COOL_AIR: {API_CELSIUS: 18, API_FAH: 64}, + API_RANGE_SP_MIN_DRY_AIR: {API_CELSIUS: 18, API_FAH: 64}, + API_RANGE_SP_MIN_EMERHEAT_AIR: {API_FAH: 59, API_CELSIUS: 15}, + API_RANGE_SP_MIN_HOT_AIR: {API_FAH: 59, API_CELSIUS: 15}, + API_RANGE_SP_MIN_STOP_AIR: {API_FAH: 59, API_CELSIUS: 15}, + API_RANGE_SP_MIN_VENT_AIR: {API_FAH: 59, API_CELSIUS: 15}, + API_SP_AIR_COOL: {API_CELSIUS: 24, API_FAH: 75}, + API_SP_AIR_DRY: {API_CELSIUS: 24, API_FAH: 75}, + API_SP_AIR_HEAT: {API_CELSIUS: 20, API_FAH: 68}, + API_SP_AIR_VENT: {API_CELSIUS: 24, API_FAH: 75}, + API_SP_AIR_STOP: {API_CELSIUS: 24, API_FAH: 75}, + API_POWER: False, API_IS_CONNECTED: True, API_WS_CONNECTED: True, - API_LOCAL_TEMP: { - API_FAH: 77, - API_CELSIUS: 25, - }, + API_LOCAL_TEMP: {API_FAH: 77, API_CELSIUS: 25}, API_WARNINGS: [], } return None diff --git a/tests/components/aladdin_connect/conftest.py b/tests/components/aladdin_connect/conftest.py index 250548e7ef2..3f5fc4f8f97 100644 --- a/tests/components/aladdin_connect/conftest.py +++ b/tests/components/aladdin_connect/conftest.py @@ -12,6 +12,10 @@ DEVICE_CONFIG_OPEN = { "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 00000000000..8f96567a49f --- /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 eb617b959a5..ba82ec6589a 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 @@ from homeassistant.const import ( 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 00000000000..4d5fe903798 --- /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/androidtv_remote/test_config_flow.py b/tests/components/androidtv_remote/test_config_flow.py index a2792efb0f3..fb4bc829160 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/anthemav/conftest.py b/tests/components/anthemav/conftest.py index 89dba9563d1..7797f08872f 100644 --- a/tests/components/anthemav/conftest.py +++ b/tests/components/anthemav/conftest.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest from homeassistant.components.anthemav.const import CONF_MODEL, DOMAIN -from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_PORT +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PORT from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -55,10 +55,10 @@ def mock_config_entry() -> MockConfigEntry: """Return the default mocked config entry.""" return MockConfigEntry( domain=DOMAIN, + title="Anthem AV", data={ CONF_HOST: "1.1.1.1", CONF_PORT: 14999, - CONF_NAME: "Anthem AV", CONF_MAC: "00:00:00:00:00:01", CONF_MODEL: "MRX 520", }, diff --git a/tests/components/anthemav/test_config_flow.py b/tests/components/anthemav/test_config_flow.py index e62fb4ba52c..caa76006976 100644 --- a/tests/components/anthemav/test_config_flow.py +++ b/tests/components/anthemav/test_config_flow.py @@ -36,10 +36,10 @@ async def test_form_with_valid_connection( await hass.async_block_till_done() assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "Anthem AV" assert result2["data"] == { "host": "1.1.1.1", "port": 14999, - "name": "Anthem AV", "mac": "00:00:00:00:00:01", "model": "MRX 520", } diff --git a/tests/components/api/test_init.py b/tests/components/api/test_init.py index b3bb6de49cf..2d570540341 100644 --- a/tests/components/api/test_init.py +++ b/tests/components/api/test_init.py @@ -9,6 +9,7 @@ import pytest 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 @@ import homeassistant.core as ha 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 @@ -591,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 6256d1dde9c..513c21f7ce5 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 .common import airplay_service, create_conf, mrp_service, raop_service 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 @@ DMAP_SERVICE = zeroconf.ZeroconfServiceInfo( 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/conftest.py b/tests/components/assist_pipeline/conftest.py index d2ec3553cf0..cde2666c1ea 100644 --- a/tests/components/assist_pipeline/conftest.py +++ b/tests/components/assist_pipeline/conftest.py @@ -184,16 +184,18 @@ class MockWakeWordEntity(wake_word.WakeWordDetectionEntity): @property def supported_wake_words(self) -> list[wake_word.WakeWord]: """Return a list of supported wake words.""" - return [wake_word.WakeWord(ww_id="test_ww", name="Test Wake Word")] + return [wake_word.WakeWord(id="test_ww", name="Test Wake Word")] async def _async_process_audio_stream( - self, stream: AsyncIterable[tuple[bytes, int]] + self, stream: AsyncIterable[tuple[bytes, int]], wake_word_id: str | None ) -> wake_word.DetectionResult | None: """Try to detect wake word(s) in an audio stream with timestamps.""" + if wake_word_id is None: + wake_word_id = self.supported_wake_words[0].id async for chunk, timestamp in stream: if chunk.startswith(b"wake word"): return wake_word.DetectionResult( - ww_id=self.supported_wake_words[0].ww_id, + wake_word_id=wake_word_id, timestamp=timestamp, queued_audio=[(b"queued audio", 0)], ) diff --git a/tests/components/assist_pipeline/snapshots/test_init.ambr b/tests/components/assist_pipeline/snapshots/test_init.ambr index 7c1cf0e2b2d..3f0582f2bfb 100644 --- a/tests/components/assist_pipeline/snapshots/test_init.ambr +++ b/tests/components/assist_pipeline/snapshots/test_init.ambr @@ -277,7 +277,7 @@ }), dict({ 'data': dict({ - 'engine': 'wake_word.test', + 'entity_id': 'wake_word.test', 'metadata': dict({ 'bit_rate': , 'channel': , @@ -292,7 +292,7 @@ 'data': dict({ 'wake_word_output': dict({ 'timestamp': 2000, - 'ww_id': 'test_ww', + 'wake_word_id': 'test_ww', }), }), 'type': , @@ -311,18 +311,6 @@ }), 'type': , }), - dict({ - 'data': dict({ - 'timestamp': 0, - }), - 'type': , - }), - dict({ - 'data': dict({ - 'timestamp': 1500, - }), - 'type': , - }), dict({ 'data': dict({ 'stt_output': dict({ @@ -389,3 +377,38 @@ }), ]) # --- +# name: test_wake_word_detection_aborted + list([ + dict({ + 'data': dict({ + 'language': 'en', + 'pipeline': , + }), + 'type': , + }), + dict({ + 'data': dict({ + 'entity_id': 'wake_word.test', + 'metadata': dict({ + 'bit_rate': , + 'channel': , + 'codec': , + 'format': , + 'sample_rate': , + }), + }), + 'type': , + }), + dict({ + 'data': dict({ + 'code': 'wake_word_detection_aborted', + 'message': '', + }), + 'type': , + }), + dict({ + 'data': None, + 'type': , + }), + ]) +# --- diff --git a/tests/components/assist_pipeline/snapshots/test_websocket.ambr b/tests/components/assist_pipeline/snapshots/test_websocket.ambr index 57fbe5f4908..7cecf9fed40 100644 --- a/tests/components/assist_pipeline/snapshots/test_websocket.ambr +++ b/tests/components/assist_pipeline/snapshots/test_websocket.ambr @@ -5,7 +5,7 @@ 'pipeline': , 'runner_data': dict({ 'stt_binary_handler_id': 1, - 'timeout': 30, + 'timeout': 300, }), }) # --- @@ -86,7 +86,7 @@ 'pipeline': , 'runner_data': dict({ 'stt_binary_handler_id': 1, - 'timeout': 30, + 'timeout': 300, }), }) # --- @@ -173,6 +173,87 @@ 'message': 'No wake-word-detection provider for: wake_word.bad-entity-id', }) # --- +# name: test_audio_pipeline_with_enhancements + dict({ + 'language': 'en', + 'pipeline': , + 'runner_data': dict({ + 'stt_binary_handler_id': 1, + 'timeout': 300, + }), + }) +# --- +# name: test_audio_pipeline_with_enhancements.1 + dict({ + 'engine': 'test', + 'metadata': dict({ + 'bit_rate': 16, + 'channel': 1, + 'codec': 'pcm', + 'format': 'wav', + 'language': 'en-US', + 'sample_rate': 16000, + }), + }) +# --- +# name: test_audio_pipeline_with_enhancements.2 + dict({ + 'stt_output': dict({ + 'text': 'test transcript', + }), + }) +# --- +# name: test_audio_pipeline_with_enhancements.3 + dict({ + 'conversation_id': None, + 'device_id': None, + 'engine': 'homeassistant', + 'intent_input': 'test transcript', + 'language': 'en', + }) +# --- +# name: test_audio_pipeline_with_enhancements.4 + dict({ + 'intent_output': dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'code': 'no_intent_match', + }), + 'language': 'en', + 'response_type': 'error', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': "Sorry, I couldn't understand that", + }), + }), + }), + }), + }) +# --- +# name: test_audio_pipeline_with_enhancements.5 + dict({ + 'engine': 'test', + 'language': 'en-US', + 'tts_input': "Sorry, I couldn't understand that", + 'voice': 'james_earl_jones', + }) +# --- +# name: test_audio_pipeline_with_enhancements.6 + dict({ + 'tts_output': dict({ + 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&voice=james_earl_jones", + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en-us_031e2ec052_test.mp3', + }), + }) +# --- +# name: test_audio_pipeline_with_enhancements.7 + None +# --- # name: test_audio_pipeline_with_wake_word dict({ 'language': 'en', @@ -185,7 +266,7 @@ # --- # name: test_audio_pipeline_with_wake_word.1 dict({ - 'engine': 'wake_word.test', + 'entity_id': 'wake_word.test', 'metadata': dict({ 'bit_rate': 16, 'channel': 1, @@ -200,7 +281,7 @@ 'wake_word_output': dict({ 'queued_audio': None, 'timestamp': 1000, - 'ww_id': 'test_ww', + 'wake_word_id': 'test_ww', }), }) # --- @@ -278,13 +359,13 @@ 'pipeline': , 'runner_data': dict({ 'stt_binary_handler_id': 1, - 'timeout': 30, + 'timeout': 300, }), }) # --- # name: test_audio_pipeline_with_wake_word_no_timeout.1 dict({ - 'engine': 'wake_word.test', + 'entity_id': 'wake_word.test', 'metadata': dict({ 'bit_rate': 16, 'channel': 1, @@ -298,7 +379,7 @@ dict({ 'wake_word_output': dict({ 'timestamp': 0, - 'ww_id': 'test_ww', + 'wake_word_id': 'test_ww', }), }) # --- @@ -379,13 +460,13 @@ 'pipeline': , 'runner_data': dict({ 'stt_binary_handler_id': 1, - 'timeout': 30, + 'timeout': 300, }), }) # --- # name: test_audio_pipeline_with_wake_word_timeout.1 dict({ - 'engine': 'wake_word.test', + 'entity_id': 'wake_word.test', 'metadata': dict({ 'bit_rate': 16, 'channel': 1, @@ -410,7 +491,7 @@ 'pipeline': , 'runner_data': dict({ 'stt_binary_handler_id': None, - 'timeout': 30, + 'timeout': 300, }), }) # --- @@ -483,7 +564,7 @@ 'pipeline': , 'runner_data': dict({ 'stt_binary_handler_id': 1, - 'timeout': 30, + 'timeout': 300, }), }) # --- @@ -509,7 +590,7 @@ 'pipeline': , 'runner_data': dict({ 'stt_binary_handler_id': None, - 'timeout': 30, + 'timeout': 300, }), }) # --- @@ -559,7 +640,7 @@ 'pipeline': , 'runner_data': dict({ 'stt_binary_handler_id': None, - 'timeout': 30, + 'timeout': 300, }), }) # --- diff --git a/tests/components/assist_pipeline/test_init.py b/tests/components/assist_pipeline/test_init.py index 8687e2ad40c..98ecae628f1 100644 --- a/tests/components/assist_pipeline/test_init.py +++ b/tests/components/assist_pipeline/test_init.py @@ -64,6 +64,9 @@ async def test_pipeline_from_audio_stream_auto( channel=stt.AudioChannels.CHANNEL_MONO, ), stt_stream=audio_data(), + audio_settings=assist_pipeline.AudioSettings( + is_vad_enabled=False, is_chunking_enabled=False + ), ) assert process_events(events) == snapshot @@ -103,6 +106,8 @@ async def test_pipeline_from_audio_stream_legacy( "tts_engine": "test", "tts_language": "en-US", "tts_voice": "Arnold Schwarzenegger", + "wake_word_entity": None, + "wake_word_id": None, } ) msg = await client.receive_json() @@ -124,6 +129,9 @@ async def test_pipeline_from_audio_stream_legacy( ), stt_stream=audio_data(), pipeline_id=pipeline_id, + audio_settings=assist_pipeline.AudioSettings( + is_vad_enabled=False, is_chunking_enabled=False + ), ) assert process_events(events) == snapshot @@ -163,6 +171,8 @@ async def test_pipeline_from_audio_stream_entity( "tts_engine": "test", "tts_language": "en-US", "tts_voice": "Arnold Schwarzenegger", + "wake_word_entity": None, + "wake_word_id": None, } ) msg = await client.receive_json() @@ -184,6 +194,9 @@ async def test_pipeline_from_audio_stream_entity( ), stt_stream=audio_data(), pipeline_id=pipeline_id, + audio_settings=assist_pipeline.AudioSettings( + is_vad_enabled=False, is_chunking_enabled=False + ), ) assert process_events(events) == snapshot @@ -223,6 +236,8 @@ async def test_pipeline_from_audio_stream_no_stt( "tts_engine": "test", "tts_language": "en-AU", "tts_voice": "Arnold Schwarzenegger", + "wake_word_entity": None, + "wake_word_id": None, } ) msg = await client.receive_json() @@ -245,6 +260,9 @@ async def test_pipeline_from_audio_stream_no_stt( ), stt_stream=audio_data(), pipeline_id=pipeline_id, + audio_settings=assist_pipeline.AudioSettings( + is_vad_enabled=False, is_chunking_enabled=False + ), ) assert not events @@ -306,44 +324,47 @@ async def test_pipeline_from_audio_stream_wake_word( # [0, 2, ...] wake_chunk_2 = bytes(it.islice(it.cycle(range(0, 256, 2)), BYTES_ONE_SECOND)) + bytes_per_chunk = int(0.01 * BYTES_ONE_SECOND) + async def audio_data(): - yield wake_chunk_1 # 1 second - yield wake_chunk_2 # 1 second + # 1 second in 10 ms chunks + i = 0 + while i < len(wake_chunk_1): + yield wake_chunk_1[i : i + bytes_per_chunk] + i += bytes_per_chunk + + # 1 second in 30 ms chunks + i = 0 + while i < len(wake_chunk_2): + yield wake_chunk_2[i : i + bytes_per_chunk] + i += bytes_per_chunk + yield b"wake word!" yield b"part1" yield b"part2" - yield b"end" yield b"" - def continue_stt(self, chunk): - # Ensure stt_vad_start event is triggered - self.in_command = True - - # Stop on fake end chunk to trigger stt_vad_end - return chunk != b"end" - - with patch( - "homeassistant.components.assist_pipeline.pipeline.VoiceCommandSegmenter.process", - continue_stt, - ): - await assist_pipeline.async_pipeline_from_audio_stream( - hass, - context=Context(), - event_callback=events.append, - stt_metadata=stt.SpeechMetadata( - language="", - format=stt.AudioFormats.WAV, - codec=stt.AudioCodecs.PCM, - bit_rate=stt.AudioBitRates.BITRATE_16, - sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, - channel=stt.AudioChannels.CHANNEL_MONO, - ), - stt_stream=audio_data(), - start_stage=assist_pipeline.PipelineStage.WAKE_WORD, - wake_word_settings=assist_pipeline.WakeWordSettings( - audio_seconds_to_buffer=1.5 - ), - ) + await assist_pipeline.async_pipeline_from_audio_stream( + hass, + context=Context(), + event_callback=events.append, + stt_metadata=stt.SpeechMetadata( + language="", + format=stt.AudioFormats.WAV, + codec=stt.AudioCodecs.PCM, + bit_rate=stt.AudioBitRates.BITRATE_16, + sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, + channel=stt.AudioChannels.CHANNEL_MONO, + ), + stt_stream=audio_data(), + start_stage=assist_pipeline.PipelineStage.WAKE_WORD, + wake_word_settings=assist_pipeline.WakeWordSettings( + audio_seconds_to_buffer=1.5 + ), + audio_settings=assist_pipeline.AudioSettings( + is_vad_enabled=False, is_chunking_enabled=False + ), + ) assert process_events(events) == snapshot @@ -351,12 +372,14 @@ async def test_pipeline_from_audio_stream_wake_word( # 2. queued audio (from mock wake word entity) # 3. part1 # 4. part2 - assert len(mock_stt_provider.received) == 4 + assert len(mock_stt_provider.received) > 3 - first_chunk = mock_stt_provider.received[0] + first_chunk = bytes( + [c_byte for c in mock_stt_provider.received[:-3] for c_byte in c] + ) assert first_chunk == wake_chunk_1[len(wake_chunk_1) // 2 :] + wake_chunk_2 - assert mock_stt_provider.received[1:] == [b"queued audio", b"part1", b"part2"] + assert mock_stt_provider.received[-3:] == [b"queued audio", b"part1", b"part2"] async def test_pipeline_save_audio( @@ -404,6 +427,9 @@ async def test_pipeline_save_audio( pipeline_id=pipeline.id, start_stage=assist_pipeline.PipelineStage.WAKE_WORD, end_stage=assist_pipeline.PipelineStage.STT, + audio_settings=assist_pipeline.AudioSettings( + is_vad_enabled=False, is_chunking_enabled=False + ), ) pipeline_dirs = list(temp_dir.iterdir()) @@ -537,3 +563,96 @@ async def test_pipeline_saved_audio_write_error( start_stage=assist_pipeline.PipelineStage.WAKE_WORD, end_stage=assist_pipeline.PipelineStage.STT, ) + + +async def test_wake_word_detection_aborted( + hass: HomeAssistant, + mock_stt_provider: MockSttProvider, + mock_wake_word_provider_entity: MockWakeWordEntity, + init_components, + pipeline_data: assist_pipeline.pipeline.PipelineData, + snapshot: SnapshotAssertion, +) -> None: + """Test creating a pipeline from an audio stream with wake word.""" + + events: list[assist_pipeline.PipelineEvent] = [] + + async def audio_data(): + yield b"silence!" + yield b"wake word!" + yield b"part1" + yield b"part2" + yield b"" + + pipeline_store = pipeline_data.pipeline_store + pipeline_id = pipeline_store.async_get_preferred_item() + pipeline = assist_pipeline.pipeline.async_get_pipeline(hass, pipeline_id) + + pipeline_input = assist_pipeline.pipeline.PipelineInput( + conversation_id=None, + device_id=None, + stt_metadata=stt.SpeechMetadata( + language="", + format=stt.AudioFormats.WAV, + codec=stt.AudioCodecs.PCM, + bit_rate=stt.AudioBitRates.BITRATE_16, + sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, + channel=stt.AudioChannels.CHANNEL_MONO, + ), + stt_stream=audio_data(), + run=assist_pipeline.pipeline.PipelineRun( + hass, + context=Context(), + pipeline=pipeline, + start_stage=assist_pipeline.PipelineStage.WAKE_WORD, + end_stage=assist_pipeline.PipelineStage.TTS, + event_callback=events.append, + tts_audio_output=None, + wake_word_settings=assist_pipeline.WakeWordSettings( + audio_seconds_to_buffer=1.5 + ), + audio_settings=assist_pipeline.AudioSettings( + is_vad_enabled=False, is_chunking_enabled=False + ), + ), + ) + await pipeline_input.validate() + + updates = pipeline.to_json() + updates.pop("id") + await pipeline_store.async_update_item( + pipeline_id, + updates, + ) + await pipeline_input.execute() + + assert process_events(events) == snapshot + + +def test_pipeline_run_equality(hass: HomeAssistant, init_components) -> None: + """Test that pipeline run equality uses unique id.""" + + def event_callback(event): + pass + + pipeline = assist_pipeline.pipeline.async_get_pipeline(hass) + run_1 = assist_pipeline.pipeline.PipelineRun( + hass, + context=Context(), + pipeline=pipeline, + start_stage=assist_pipeline.PipelineStage.STT, + end_stage=assist_pipeline.PipelineStage.TTS, + event_callback=event_callback, + ) + run_2 = assist_pipeline.pipeline.PipelineRun( + hass, + context=Context(), + pipeline=pipeline, + start_stage=assist_pipeline.PipelineStage.STT, + end_stage=assist_pipeline.PipelineStage.TTS, + event_callback=event_callback, + ) + + assert run_1 == run_1 + assert run_1 != run_2 + assert run_1 != 1234 diff --git a/tests/components/assist_pipeline/test_pipeline.py b/tests/components/assist_pipeline/test_pipeline.py index 32468e3af91..5a84f4c2716 100644 --- a/tests/components/assist_pipeline/test_pipeline.py +++ b/tests/components/assist_pipeline/test_pipeline.py @@ -8,15 +8,16 @@ from homeassistant.components.assist_pipeline.const import DOMAIN from homeassistant.components.assist_pipeline.pipeline import ( STORAGE_KEY, STORAGE_VERSION, + STORAGE_VERSION_MINOR, Pipeline, PipelineData, PipelineStorageCollection, + PipelineStore, async_create_default_pipeline, async_get_pipeline, async_get_pipelines, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.storage import Store from homeassistant.setup import async_setup_component from . import MANY_LANGUAGES @@ -45,6 +46,8 @@ async def test_load_pipelines(hass: HomeAssistant, init_components) -> None: "tts_engine": "tts_engine_1", "tts_language": "language_1", "tts_voice": "Arnold Schwarzenegger", + "wake_word_entity": "wakeword_entity_1", + "wake_word_id": "wakeword_id_1", }, { "conversation_engine": "conversation_engine_2", @@ -56,6 +59,8 @@ async def test_load_pipelines(hass: HomeAssistant, init_components) -> None: "tts_engine": "tts_engine_2", "tts_language": "language_2", "tts_voice": "The Voice", + "wake_word_entity": "wakeword_entity_2", + "wake_word_id": "wakeword_id_2", }, { "conversation_engine": "conversation_engine_3", @@ -67,6 +72,8 @@ async def test_load_pipelines(hass: HomeAssistant, init_components) -> None: "tts_engine": None, "tts_language": None, "tts_voice": None, + "wake_word_entity": "wakeword_entity_3", + "wake_word_id": "wakeword_id_3", }, ] pipeline_ids = [] @@ -81,7 +88,11 @@ async def test_load_pipelines(hass: HomeAssistant, init_components) -> None: await store1.async_delete_item(pipeline_ids[1]) assert len(store1.data) == 3 - store2 = PipelineStorageCollection(Store(hass, STORAGE_VERSION, STORAGE_KEY)) + store2 = PipelineStorageCollection( + PipelineStore( + hass, STORAGE_VERSION, STORAGE_KEY, minor_version=STORAGE_VERSION_MINOR + ) + ) await flush_store(store1.store) await store2.async_load() @@ -96,6 +107,71 @@ async def test_loading_pipelines_from_storage( hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: """Test loading stored pipelines on start.""" + hass_storage[STORAGE_KEY] = { + "version": STORAGE_VERSION, + "minor_version": STORAGE_VERSION_MINOR, + "key": "assist_pipeline.pipelines", + "data": { + "items": [ + { + "conversation_engine": "conversation_engine_1", + "conversation_language": "language_1", + "id": "01GX8ZWBAQYWNB1XV3EXEZ75DY", + "language": "language_1", + "name": "name_1", + "stt_engine": "stt_engine_1", + "stt_language": "language_1", + "tts_engine": "tts_engine_1", + "tts_language": "language_1", + "tts_voice": "Arnold Schwarzenegger", + "wake_word_entity": "wakeword_entity_1", + "wake_word_id": "wakeword_id_1", + }, + { + "conversation_engine": "conversation_engine_2", + "conversation_language": "language_2", + "id": "01GX8ZWBAQTKFQNK4W7Q4CTRCX", + "language": "language_2", + "name": "name_2", + "stt_engine": "stt_engine_2", + "stt_language": "language_2", + "tts_engine": "tts_engine_2", + "tts_language": "language_2", + "tts_voice": "The Voice", + "wake_word_entity": "wakeword_entity_2", + "wake_word_id": "wakeword_id_2", + }, + { + "conversation_engine": "conversation_engine_3", + "conversation_language": "language_3", + "id": "01GX8ZWBAQSV1HP3WGJPFWEJ8J", + "language": "language_3", + "name": "name_3", + "stt_engine": None, + "stt_language": None, + "tts_engine": None, + "tts_language": None, + "tts_voice": None, + "wake_word_entity": "wakeword_entity_3", + "wake_word_id": "wakeword_id_3", + }, + ], + "preferred_item": "01GX8ZWBAQYWNB1XV3EXEZ75DY", + }, + } + + assert await async_setup_component(hass, "assist_pipeline", {}) + + pipeline_data: PipelineData = hass.data[DOMAIN] + store = pipeline_data.pipeline_store + assert len(store.data) == 3 + assert store.async_get_preferred_item() == "01GX8ZWBAQYWNB1XV3EXEZ75DY" + + +async def test_migrate_pipeline_store( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """Test loading stored pipelines from an older version.""" hass_storage[STORAGE_KEY] = { "version": 1, "minor_version": 1, @@ -173,6 +249,8 @@ async def test_create_default_pipeline( tts_engine="test", tts_language="en-US", tts_voice="james_earl_jones", + wake_word_entity=None, + wake_word_id=None, ) @@ -213,6 +291,8 @@ async def test_get_pipelines(hass: HomeAssistant) -> None: tts_engine=None, tts_language=None, tts_voice=None, + wake_word_entity=None, + wake_word_id=None, ) ] @@ -258,6 +338,8 @@ async def test_default_pipeline_no_stt_tts( tts_engine=None, tts_language=None, tts_voice=None, + wake_word_entity=None, + wake_word_id=None, ) @@ -318,6 +400,8 @@ async def test_default_pipeline( tts_engine="test", tts_language=tts_language, tts_voice=None, + wake_word_entity=None, + wake_word_id=None, ) @@ -347,6 +431,8 @@ async def test_default_pipeline_unsupported_stt_language( tts_engine="test", tts_language="en-US", tts_voice="james_earl_jones", + wake_word_entity=None, + wake_word_id=None, ) @@ -376,6 +462,8 @@ async def test_default_pipeline_unsupported_tts_language( tts_engine=None, tts_language=None, tts_voice=None, + wake_word_entity=None, + wake_word_id=None, ) @@ -424,4 +512,6 @@ async def test_default_pipeline_cloud( tts_engine="cloud", tts_language="en-US", tts_voice="james_earl_jones", + wake_word_entity=None, + wake_word_id=None, ) diff --git a/tests/components/assist_pipeline/test_select.py b/tests/components/assist_pipeline/test_select.py index 1419eb58750..090c1034e4e 100644 --- a/tests/components/assist_pipeline/test_select.py +++ b/tests/components/assist_pipeline/test_select.py @@ -70,6 +70,8 @@ async def pipeline_1( "tts_voice": None, "stt_engine": None, "stt_language": None, + "wake_word_entity": None, + "wake_word_id": None, } ) @@ -90,6 +92,8 @@ async def pipeline_2( "tts_voice": None, "stt_engine": None, "stt_language": None, + "wake_word_entity": None, + "wake_word_id": None, } ) diff --git a/tests/components/assist_pipeline/test_vad.py b/tests/components/assist_pipeline/test_vad.py index 4dc8c8f6197..57b567c49df 100644 --- a/tests/components/assist_pipeline/test_vad.py +++ b/tests/components/assist_pipeline/test_vad.py @@ -1,14 +1,15 @@ -"""Tests for webrtcvad voice command segmenter.""" +"""Tests for voice command segmenter.""" import itertools as it from unittest.mock import patch from homeassistant.components.assist_pipeline.vad import ( AudioBuffer, + VoiceActivityDetector, VoiceCommandSegmenter, chunk_samples, ) -_ONE_SECOND = 16000 * 2 # 16Khz 16-bit +_ONE_SECOND = 1.0 def test_silence() -> None: @@ -16,87 +17,85 @@ def test_silence() -> None: segmenter = VoiceCommandSegmenter() # True return value indicates voice command has not finished - assert segmenter.process(bytes(_ONE_SECOND * 3)) + assert segmenter.process(_ONE_SECOND * 3, False) def test_speech() -> None: """Test that silence + speech + silence triggers a voice command.""" - def is_speech(self, chunk, sample_rate): + def is_speech(chunk): """Anything non-zero is speech.""" return sum(chunk) > 0 - with patch( - "webrtcvad.Vad.is_speech", - new=is_speech, - ): - segmenter = VoiceCommandSegmenter() + segmenter = VoiceCommandSegmenter() - # silence - assert segmenter.process(bytes(_ONE_SECOND)) + # silence + assert segmenter.process(_ONE_SECOND, False) - # "speech" - assert segmenter.process(bytes([255] * _ONE_SECOND)) + # "speech" + assert segmenter.process(_ONE_SECOND, True) - # silence - # False return value indicates voice command is finished - assert not segmenter.process(bytes(_ONE_SECOND)) + # silence + # False return value indicates voice command is finished + assert not segmenter.process(_ONE_SECOND, False) def test_audio_buffer() -> None: """Test audio buffer wrapping.""" - def is_speech(self, chunk, sample_rate): - """Disable VAD.""" - return False + class DisabledVad(VoiceActivityDetector): + def is_speech(self, chunk): + return False - with patch( - "webrtcvad.Vad.is_speech", - new=is_speech, - ): - segmenter = VoiceCommandSegmenter() - bytes_per_chunk = segmenter.vad_samples_per_chunk * 2 + @property + def samples_per_chunk(self): + return 160 # 10 ms - with patch.object( - segmenter, "_process_chunk", return_value=True - ) as mock_process: - # Partially fill audio buffer - half_chunk = bytes(it.islice(it.cycle(range(256)), bytes_per_chunk // 2)) - segmenter.process(half_chunk) + vad = DisabledVad() + bytes_per_chunk = vad.samples_per_chunk * 2 + vad_buffer = AudioBuffer(bytes_per_chunk) + segmenter = VoiceCommandSegmenter() - assert not mock_process.called - assert segmenter.audio_buffer == half_chunk + with patch.object(vad, "is_speech", return_value=False) as mock_process: + # Partially fill audio buffer + half_chunk = bytes(it.islice(it.cycle(range(256)), bytes_per_chunk // 2)) + segmenter.process_with_vad(half_chunk, vad, vad_buffer) - # Fill and wrap with 1/4 chunk left over - three_quarters_chunk = bytes( - it.islice(it.cycle(range(256)), int(0.75 * bytes_per_chunk)) - ) - segmenter.process(three_quarters_chunk) + assert not mock_process.called + assert vad_buffer is not None + assert vad_buffer.bytes() == half_chunk - assert mock_process.call_count == 1 - assert ( - segmenter.audio_buffer - == three_quarters_chunk[ - len(three_quarters_chunk) - (bytes_per_chunk // 4) : - ] - ) - assert ( - mock_process.call_args[0][0] - == half_chunk + three_quarters_chunk[: bytes_per_chunk // 2] - ) + # Fill and wrap with 1/4 chunk left over + three_quarters_chunk = bytes( + it.islice(it.cycle(range(256)), int(0.75 * bytes_per_chunk)) + ) + segmenter.process_with_vad(three_quarters_chunk, vad, vad_buffer) - # Run 2 chunks through - segmenter.reset() - assert len(segmenter.audio_buffer) == 0 + assert mock_process.call_count == 1 + assert ( + vad_buffer.bytes() + == three_quarters_chunk[ + len(three_quarters_chunk) - (bytes_per_chunk // 4) : + ] + ) + assert ( + mock_process.call_args[0][0] + == half_chunk + three_quarters_chunk[: bytes_per_chunk // 2] + ) - mock_process.reset_mock() - two_chunks = bytes(it.islice(it.cycle(range(256)), bytes_per_chunk * 2)) - segmenter.process(two_chunks) + # Run 2 chunks through + segmenter.reset() + vad_buffer.clear() + assert len(vad_buffer) == 0 - assert mock_process.call_count == 2 - assert len(segmenter.audio_buffer) == 0 - assert mock_process.call_args_list[0][0][0] == two_chunks[:bytes_per_chunk] - assert mock_process.call_args_list[1][0][0] == two_chunks[bytes_per_chunk:] + mock_process.reset_mock() + two_chunks = bytes(it.islice(it.cycle(range(256)), bytes_per_chunk * 2)) + segmenter.process_with_vad(two_chunks, vad, vad_buffer) + + assert mock_process.call_count == 2 + assert len(vad_buffer) == 0 + assert mock_process.call_args_list[0][0][0] == two_chunks[:bytes_per_chunk] + assert mock_process.call_args_list[1][0][0] == two_chunks[bytes_per_chunk:] def test_partial_chunk() -> None: @@ -125,3 +124,43 @@ def test_chunk_samples_leftover() -> None: assert len(chunks) == 1 assert leftover_chunk_buffer.bytes() == bytes([5, 6]) + + +def test_vad_no_chunking() -> None: + """Test VAD that doesn't require chunking.""" + + class VadNoChunk(VoiceActivityDetector): + def is_speech(self, chunk: bytes) -> bool: + return sum(chunk) > 0 + + @property + def samples_per_chunk(self) -> int | None: + return None + + vad = VadNoChunk() + segmenter = VoiceCommandSegmenter( + speech_seconds=1.0, silence_seconds=1.0, reset_seconds=0.5 + ) + silence = bytes([0] * 16000) + speech = bytes([255] * (16000 // 2)) + + # Test with differently-sized chunks + assert vad.is_speech(speech) + assert not vad.is_speech(silence) + + # Simulate voice command + assert segmenter.process_with_vad(silence, vad, None) + # begin + assert segmenter.process_with_vad(speech, vad, None) + assert segmenter.process_with_vad(speech, vad, None) + assert segmenter.process_with_vad(speech, vad, None) + # reset with silence + assert segmenter.process_with_vad(silence, vad, None) + # resume + assert segmenter.process_with_vad(speech, vad, None) + assert segmenter.process_with_vad(speech, vad, None) + assert segmenter.process_with_vad(speech, vad, None) + assert segmenter.process_with_vad(speech, vad, None) + # end + assert segmenter.process_with_vad(silence, vad, None) + assert not segmenter.process_with_vad(silence, vad, None) diff --git a/tests/components/assist_pipeline/test_websocket.py b/tests/components/assist_pipeline/test_websocket.py index ca631be4549..f995a0d3577 100644 --- a/tests/components/assist_pipeline/test_websocket.py +++ b/tests/components/assist_pipeline/test_websocket.py @@ -62,8 +62,8 @@ async def test_text_only_pipeline( events.append(msg["event"]) pipeline_data: PipelineData = hass.data[DOMAIN] - pipeline_id = list(pipeline_data.pipeline_runs)[0] - pipeline_run_id = list(pipeline_data.pipeline_runs[pipeline_id])[0] + pipeline_id = list(pipeline_data.pipeline_debug)[0] + pipeline_run_id = list(pipeline_data.pipeline_debug[pipeline_id])[0] await client.send_json_auto_id( { @@ -107,6 +107,7 @@ async def test_audio_pipeline( assert msg["event"]["type"] == "run-start" msg["event"]["data"]["pipeline"] = ANY assert msg["event"]["data"] == snapshot + handler_id = msg["event"]["data"]["runner_data"]["stt_binary_handler_id"] events.append(msg["event"]) # stt @@ -116,7 +117,7 @@ async def test_audio_pipeline( events.append(msg["event"]) # End of audio stream (handler id + empty payload) - await client.send_bytes(bytes([1])) + await client.send_bytes(bytes([handler_id])) msg = await client.receive_json() assert msg["event"]["type"] == "stt-end" @@ -152,8 +153,8 @@ async def test_audio_pipeline( events.append(msg["event"]) pipeline_data: PipelineData = hass.data[DOMAIN] - pipeline_id = list(pipeline_data.pipeline_runs)[0] - pipeline_run_id = list(pipeline_data.pipeline_runs[pipeline_id])[0] + pipeline_id = list(pipeline_data.pipeline_debug)[0] + pipeline_run_id = list(pipeline_data.pipeline_debug[pipeline_id])[0] await client.send_json_auto_id( { @@ -240,6 +241,8 @@ async def test_audio_pipeline_with_wake_word_no_timeout( "input": { "sample_rate": 16000, "timeout": 0, + "no_vad": True, + "no_chunking": True, }, } ) @@ -253,6 +256,7 @@ async def test_audio_pipeline_with_wake_word_no_timeout( assert msg["event"]["type"] == "run-start" msg["event"]["data"]["pipeline"] = ANY assert msg["event"]["data"] == snapshot + handler_id = msg["event"]["data"]["runner_data"]["stt_binary_handler_id"] events.append(msg["event"]) # wake_word @@ -276,7 +280,7 @@ async def test_audio_pipeline_with_wake_word_no_timeout( events.append(msg["event"]) # End of audio stream (handler id + empty payload) - await client.send_bytes(bytes([1])) + await client.send_bytes(bytes([handler_id])) msg = await client.receive_json() assert msg["event"]["type"] == "stt-end" @@ -312,8 +316,8 @@ async def test_audio_pipeline_with_wake_word_no_timeout( events.append(msg["event"]) pipeline_data: PipelineData = hass.data[DOMAIN] - pipeline_id = list(pipeline_data.pipeline_runs)[0] - pipeline_run_id = list(pipeline_data.pipeline_runs[pipeline_id])[0] + pipeline_id = list(pipeline_data.pipeline_debug)[0] + pipeline_run_id = list(pipeline_data.pipeline_debug[pipeline_id])[0] await client.send_json_auto_id( { @@ -337,7 +341,7 @@ async def test_audio_pipeline_no_wake_word_engine( client = await hass_ws_client(hass) with patch( - "homeassistant.components.wake_word.async_default_engine", return_value=None + "homeassistant.components.wake_word.async_default_entity", return_value=None ): await client.send_json_auto_id( { @@ -367,7 +371,7 @@ async def test_audio_pipeline_no_wake_word_entity( client = await hass_ws_client(hass) with patch( - "homeassistant.components.wake_word.async_default_engine", + "homeassistant.components.wake_word.async_default_entity", return_value="wake_word.bad-entity-id", ), patch( "homeassistant.components.wake_word.async_get_wake_word_detection_entity", @@ -448,8 +452,8 @@ async def test_intent_timeout( events.append(msg["event"]) pipeline_data: PipelineData = hass.data[DOMAIN] - pipeline_id = list(pipeline_data.pipeline_runs)[0] - pipeline_run_id = list(pipeline_data.pipeline_runs[pipeline_id])[0] + pipeline_id = list(pipeline_data.pipeline_debug)[0] + pipeline_run_id = list(pipeline_data.pipeline_debug[pipeline_id])[0] await client.send_json_auto_id( { @@ -501,8 +505,8 @@ async def test_text_pipeline_timeout( events.append(msg["event"]) pipeline_data: PipelineData = hass.data[DOMAIN] - pipeline_id = list(pipeline_data.pipeline_runs)[0] - pipeline_run_id = list(pipeline_data.pipeline_runs[pipeline_id])[0] + pipeline_id = list(pipeline_data.pipeline_debug)[0] + pipeline_run_id = list(pipeline_data.pipeline_debug[pipeline_id])[0] await client.send_json_auto_id( { @@ -569,8 +573,8 @@ async def test_intent_failed( events.append(msg["event"]) pipeline_data: PipelineData = hass.data[DOMAIN] - pipeline_id = list(pipeline_data.pipeline_runs)[0] - pipeline_run_id = list(pipeline_data.pipeline_runs[pipeline_id])[0] + pipeline_id = list(pipeline_data.pipeline_debug)[0] + pipeline_run_id = list(pipeline_data.pipeline_debug[pipeline_id])[0] await client.send_json_auto_id( { @@ -624,8 +628,8 @@ async def test_audio_pipeline_timeout( events.append(msg["event"]) pipeline_data: PipelineData = hass.data[DOMAIN] - pipeline_id = list(pipeline_data.pipeline_runs)[0] - pipeline_run_id = list(pipeline_data.pipeline_runs[pipeline_id])[0] + pipeline_id = list(pipeline_data.pipeline_debug)[0] + pipeline_run_id = list(pipeline_data.pipeline_debug[pipeline_id])[0] await client.send_json_auto_id( { @@ -731,6 +735,7 @@ async def test_stt_stream_failed( assert msg["event"]["type"] == "run-start" msg["event"]["data"]["pipeline"] = ANY assert msg["event"]["data"] == snapshot + handler_id = msg["event"]["data"]["runner_data"]["stt_binary_handler_id"] events.append(msg["event"]) # stt @@ -740,7 +745,7 @@ async def test_stt_stream_failed( events.append(msg["event"]) # End of audio stream (handler id + empty payload) - await client.send_bytes(b"1") + await client.send_bytes(bytes([handler_id])) # stt error msg = await client.receive_json() @@ -755,8 +760,8 @@ async def test_stt_stream_failed( events.append(msg["event"]) pipeline_data: PipelineData = hass.data[DOMAIN] - pipeline_id = list(pipeline_data.pipeline_runs)[0] - pipeline_run_id = list(pipeline_data.pipeline_runs[pipeline_id])[0] + pipeline_id = list(pipeline_data.pipeline_debug)[0] + pipeline_run_id = list(pipeline_data.pipeline_debug[pipeline_id])[0] await client.send_json_auto_id( { @@ -823,8 +828,8 @@ async def test_tts_failed( events.append(msg["event"]) pipeline_data: PipelineData = hass.data[DOMAIN] - pipeline_id = list(pipeline_data.pipeline_runs)[0] - pipeline_run_id = list(pipeline_data.pipeline_runs[pipeline_id])[0] + pipeline_id = list(pipeline_data.pipeline_debug)[0] + pipeline_run_id = list(pipeline_data.pipeline_debug[pipeline_id])[0] await client.send_json_auto_id( { @@ -936,6 +941,8 @@ async def test_add_pipeline( "tts_engine": "test_tts_engine", "tts_language": "test_language", "tts_voice": "Arnold Schwarzenegger", + "wake_word_entity": "wakeword_entity_1", + "wake_word_id": "wakeword_id_1", } ) msg = await client.receive_json() @@ -951,6 +958,8 @@ async def test_add_pipeline( "tts_engine": "test_tts_engine", "tts_language": "test_language", "tts_voice": "Arnold Schwarzenegger", + "wake_word_entity": "wakeword_entity_1", + "wake_word_id": "wakeword_id_1", } assert len(pipeline_store.data) == 2 @@ -966,6 +975,8 @@ async def test_add_pipeline( tts_engine="test_tts_engine", tts_language="test_language", tts_voice="Arnold Schwarzenegger", + wake_word_entity="wakeword_entity_1", + wake_word_id="wakeword_id_1", ) await client.send_json_auto_id( @@ -1000,6 +1011,8 @@ async def test_add_pipeline_missing_language( "tts_engine": "test_tts_engine", "tts_language": "test_language", "tts_voice": "Arnold Schwarzenegger", + "wake_word_entity": "wakeword_entity_1", + "wake_word_id": "wakeword_id_1", } ) msg = await client.receive_json() @@ -1018,6 +1031,8 @@ async def test_add_pipeline_missing_language( "tts_engine": "test_tts_engine", "tts_language": None, "tts_voice": "Arnold Schwarzenegger", + "wake_word_entity": "wakeword_entity_1", + "wake_word_id": "wakeword_id_1", } ) msg = await client.receive_json() @@ -1045,6 +1060,8 @@ async def test_delete_pipeline( "tts_engine": "test_tts_engine", "tts_language": "test_language", "tts_voice": "Arnold Schwarzenegger", + "wake_word_entity": "wakeword_entity_1", + "wake_word_id": "wakeword_id_1", } ) msg = await client.receive_json() @@ -1063,6 +1080,8 @@ async def test_delete_pipeline( "tts_engine": "test_tts_engine", "tts_language": "test_language", "tts_voice": "Arnold Schwarzenegger", + "wake_word_entity": "wakeword_entity_2", + "wake_word_id": "wakeword_id_2", } ) msg = await client.receive_json() @@ -1143,6 +1162,8 @@ async def test_get_pipeline( "tts_engine": "test", "tts_language": "en-US", "tts_voice": "james_earl_jones", + "wake_word_entity": None, + "wake_word_id": None, } await client.send_json_auto_id( @@ -1170,6 +1191,8 @@ async def test_get_pipeline( "tts_engine": "test_tts_engine", "tts_language": "test_language", "tts_voice": "Arnold Schwarzenegger", + "wake_word_entity": "wakeword_entity_1", + "wake_word_id": "wakeword_id_1", } ) msg = await client.receive_json() @@ -1196,6 +1219,8 @@ async def test_get_pipeline( "tts_engine": "test_tts_engine", "tts_language": "test_language", "tts_voice": "Arnold Schwarzenegger", + "wake_word_entity": "wakeword_entity_1", + "wake_word_id": "wakeword_id_1", } @@ -1221,6 +1246,8 @@ async def test_list_pipelines( "tts_engine": "test", "tts_language": "en-US", "tts_voice": "james_earl_jones", + "wake_word_entity": None, + "wake_word_id": None, } ], "preferred_pipeline": ANY, @@ -1248,6 +1275,8 @@ async def test_update_pipeline( "tts_engine": "new_tts_engine", "tts_language": "new_tts_language", "tts_voice": "new_tts_voice", + "wake_word_entity": "new_wakeword_entity", + "wake_word_id": "new_wakeword_id", } ) msg = await client.receive_json() @@ -1269,6 +1298,8 @@ async def test_update_pipeline( "tts_engine": "test_tts_engine", "tts_language": "test_language", "tts_voice": "Arnold Schwarzenegger", + "wake_word_entity": "wakeword_entity_1", + "wake_word_id": "wakeword_id_1", } ) msg = await client.receive_json() @@ -1289,6 +1320,8 @@ async def test_update_pipeline( "tts_engine": "new_tts_engine", "tts_language": "new_tts_language", "tts_voice": "new_tts_voice", + "wake_word_entity": "new_wakeword_entity", + "wake_word_id": "new_wakeword_id", } ) msg = await client.receive_json() @@ -1304,6 +1337,8 @@ async def test_update_pipeline( "tts_engine": "new_tts_engine", "tts_language": "new_tts_language", "tts_voice": "new_tts_voice", + "wake_word_entity": "new_wakeword_entity", + "wake_word_id": "new_wakeword_id", } assert len(pipeline_store.data) == 2 @@ -1319,6 +1354,8 @@ async def test_update_pipeline( tts_engine="new_tts_engine", tts_language="new_tts_language", tts_voice="new_tts_voice", + wake_word_entity="new_wakeword_entity", + wake_word_id="new_wakeword_id", ) await client.send_json_auto_id( @@ -1334,6 +1371,8 @@ async def test_update_pipeline( "tts_engine": None, "tts_language": None, "tts_voice": None, + "wake_word_entity": None, + "wake_word_id": None, } ) msg = await client.receive_json() @@ -1349,6 +1388,8 @@ async def test_update_pipeline( "tts_engine": None, "tts_language": None, "tts_voice": None, + "wake_word_entity": None, + "wake_word_id": None, } pipeline = pipeline_store.data[pipeline_id] @@ -1363,6 +1404,8 @@ async def test_update_pipeline( tts_engine=None, tts_language=None, tts_voice=None, + wake_word_entity=None, + wake_word_id=None, ) @@ -1386,6 +1429,8 @@ async def test_set_preferred_pipeline( "tts_engine": "test_tts_engine", "tts_language": "test_language", "tts_voice": "Arnold Schwarzenegger", + "wake_word_entity": "wakeword_entity_1", + "wake_word_id": "wakeword_id_1", } ) msg = await client.receive_json() @@ -1449,6 +1494,7 @@ async def test_audio_pipeline_debug( assert msg["event"]["type"] == "run-start" msg["event"]["data"]["pipeline"] = ANY assert msg["event"]["data"] == snapshot + handler_id = msg["event"]["data"]["runner_data"]["stt_binary_handler_id"] events.append(msg["event"]) # stt @@ -1458,7 +1504,7 @@ async def test_audio_pipeline_debug( events.append(msg["event"]) # End of audio stream (handler id + empty payload) - await client.send_bytes(bytes([1])) + await client.send_bytes(bytes([handler_id])) msg = await client.receive_json() assert msg["event"]["type"] == "stt-end" @@ -1633,3 +1679,129 @@ 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"]} + + +async def test_audio_pipeline_with_enhancements( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_components, + snapshot: SnapshotAssertion, +) -> None: + """Test events from a pipeline run with audio input/output.""" + events = [] + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "assist_pipeline/run", + "start_stage": "stt", + "end_stage": "tts", + "input": { + "sample_rate": 16000, + # Enhancements + "noise_suppression_level": 2, + "auto_gain_dbfs": 15, + "volume_multiplier": 2.0, + }, + } + ) + + # result + msg = await client.receive_json() + assert msg["success"] + + # run start + msg = await client.receive_json() + assert msg["event"]["type"] == "run-start" + msg["event"]["data"]["pipeline"] = ANY + assert msg["event"]["data"] == snapshot + handler_id = msg["event"]["data"]["runner_data"]["stt_binary_handler_id"] + events.append(msg["event"]) + + # stt + msg = await client.receive_json() + assert msg["event"]["type"] == "stt-start" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) + + # One second of silence. + # This will pass through the audio enhancement pipeline, but we don't test + # the actual output. + await client.send_bytes(bytes([handler_id]) + bytes(16000 * 2)) + + # End of audio stream (handler id + empty payload) + await client.send_bytes(bytes([handler_id])) + + msg = await client.receive_json() + assert msg["event"]["type"] == "stt-end" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) + + # intent + msg = await client.receive_json() + assert msg["event"]["type"] == "intent-start" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) + + msg = await client.receive_json() + assert msg["event"]["type"] == "intent-end" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) + + # text-to-speech + msg = await client.receive_json() + assert msg["event"]["type"] == "tts-start" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) + + msg = await client.receive_json() + assert msg["event"]["type"] == "tts-end" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) + + # run end + msg = await client.receive_json() + assert msg["event"]["type"] == "run-end" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) + + pipeline_data: PipelineData = hass.data[DOMAIN] + pipeline_id = list(pipeline_data.pipeline_debug)[0] + pipeline_run_id = list(pipeline_data.pipeline_debug[pipeline_id])[0] + + await client.send_json_auto_id( + { + "type": "assist_pipeline/pipeline_debug/get", + "pipeline_id": pipeline_id, + "pipeline_run_id": pipeline_run_id, + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == {"events": events} diff --git a/tests/components/august/fixtures/get_activity.lock_from_manual.json b/tests/components/august/fixtures/get_activity.lock_from_manual.json new file mode 100644 index 00000000000..e2fc195cfda --- /dev/null +++ b/tests/components/august/fixtures/get_activity.lock_from_manual.json @@ -0,0 +1,39 @@ +[ + { + "entities": { + "activity": "mockActivity2", + "house": "123", + "device": "online_with_doorsense", + "callingUser": "mockUserId2", + "otherUser": "deleted" + }, + "callingUser": { + "LastName": "elven princess", + "UserID": "mockUserId2", + "FirstName": "Your favorite" + }, + "otherUser": { + "LastName": "User", + "UserName": "deleteduser", + "FirstName": "Unknown", + "UserID": "deleted", + "PhoneNo": "deleted" + }, + "deviceType": "lock", + "deviceName": "MockHouseTDoor", + "action": "lock", + "dateTime": 1582007218000, + "info": { + "remote": false, + "keypad": false, + "manual": true, + "tag": false, + "DateLogActionID": "ABC+Time" + }, + "deviceID": "online_with_doorsense", + "house": { + "houseName": "MockHouse", + "houseID": "123" + } + } +] diff --git a/tests/components/august/fixtures/get_activity.unlock_from_manual.json b/tests/components/august/fixtures/get_activity.unlock_from_manual.json new file mode 100644 index 00000000000..e8bf95818ce --- /dev/null +++ b/tests/components/august/fixtures/get_activity.unlock_from_manual.json @@ -0,0 +1,39 @@ +[ + { + "entities": { + "activity": "mockActivity2", + "house": "123", + "device": "online_with_doorsense", + "callingUser": "mockUserId2", + "otherUser": "deleted" + }, + "callingUser": { + "LastName": "elven princess", + "UserID": "mockUserId2", + "FirstName": "Your favorite" + }, + "otherUser": { + "LastName": "User", + "UserName": "deleteduser", + "FirstName": "Unknown", + "UserID": "deleted", + "PhoneNo": "deleted" + }, + "deviceType": "lock", + "deviceName": "MockHouseTDoor", + "action": "unlock", + "dateTime": 1582007218000, + "info": { + "remote": false, + "keypad": false, + "manual": true, + "tag": false, + "DateLogActionID": "ABC+Time" + }, + "deviceID": "online_with_doorsense", + "house": { + "houseName": "MockHouse", + "houseID": "123" + } + } +] diff --git a/tests/components/august/fixtures/get_activity.unlock_from_tag.json b/tests/components/august/fixtures/get_activity.unlock_from_tag.json new file mode 100644 index 00000000000..57876428677 --- /dev/null +++ b/tests/components/august/fixtures/get_activity.unlock_from_tag.json @@ -0,0 +1,39 @@ +[ + { + "entities": { + "activity": "mockActivity2", + "house": "123", + "device": "online_with_doorsense", + "callingUser": "mockUserId2", + "otherUser": "deleted" + }, + "callingUser": { + "LastName": "elven princess", + "UserID": "mockUserId2", + "FirstName": "Your favorite" + }, + "otherUser": { + "LastName": "User", + "UserName": "deleteduser", + "FirstName": "Unknown", + "UserID": "deleted", + "PhoneNo": "deleted" + }, + "deviceType": "lock", + "deviceName": "MockHouseTDoor", + "action": "unlock", + "dateTime": 1582007218000, + "info": { + "remote": false, + "keypad": false, + "manual": false, + "tag": true, + "DateLogActionID": "ABC+Time" + }, + "deviceID": "online_with_doorsense", + "house": { + "houseName": "MockHouse", + "houseID": "123" + } + } +] diff --git a/tests/components/august/test_sensor.py b/tests/components/august/test_sensor.py index 7987ab88b1e..d0da8ce6d53 100644 --- a/tests/components/august/test_sensor.py +++ b/tests/components/august/test_sensor.py @@ -1,6 +1,14 @@ """The sensor tests for the august platform.""" -from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, PERCENTAGE, STATE_UNKNOWN -from homeassistant.core import HomeAssistant +from typing import Any + +from homeassistant import core as ha +from homeassistant.const import ( + ATTR_ENTITY_PICTURE, + ATTR_UNIT_OF_MEASUREMENT, + PERCENTAGE, + STATE_UNKNOWN, +) +from homeassistant.core import CoreState, HomeAssistant from homeassistant.helpers import entity_registry as er from .mocks import ( @@ -11,6 +19,8 @@ from .mocks import ( _mock_lock_from_fixture, ) +from tests.common import mock_restore_cache_with_extra_data + async def test_create_doorbell(hass: HomeAssistant) -> None: """Test creation of a doorbell.""" @@ -139,34 +149,15 @@ async def test_lock_operator_bluetooth(hass: HomeAssistant) -> None: "sensor.online_with_doorsense_name_operator" ) assert lock_operator_sensor - assert ( - hass.states.get("sensor.online_with_doorsense_name_operator").state - == "Your favorite elven princess" - ) - assert ( - hass.states.get("sensor.online_with_doorsense_name_operator").attributes[ - "remote" - ] - is False - ) - assert ( - hass.states.get("sensor.online_with_doorsense_name_operator").attributes[ - "keypad" - ] - is False - ) - assert ( - hass.states.get("sensor.online_with_doorsense_name_operator").attributes[ - "autorelock" - ] - is False - ) - assert ( - hass.states.get("sensor.online_with_doorsense_name_operator").attributes[ - "method" - ] - == "mobile" - ) + + state = hass.states.get("sensor.online_with_doorsense_name_operator") + assert state.state == "Your favorite elven princess" + assert state.attributes["manual"] is False + assert state.attributes["tag"] is False + assert state.attributes["remote"] is False + assert state.attributes["keypad"] is False + assert state.attributes["autorelock"] is False + assert state.attributes["method"] == "mobile" async def test_lock_operator_keypad(hass: HomeAssistant) -> None: @@ -183,34 +174,15 @@ async def test_lock_operator_keypad(hass: HomeAssistant) -> None: "sensor.online_with_doorsense_name_operator" ) assert lock_operator_sensor - assert ( - hass.states.get("sensor.online_with_doorsense_name_operator").state - == "Your favorite elven princess" - ) - assert ( - hass.states.get("sensor.online_with_doorsense_name_operator").attributes[ - "remote" - ] - is False - ) - assert ( - hass.states.get("sensor.online_with_doorsense_name_operator").attributes[ - "keypad" - ] - is True - ) - assert ( - hass.states.get("sensor.online_with_doorsense_name_operator").attributes[ - "autorelock" - ] - is False - ) - assert ( - hass.states.get("sensor.online_with_doorsense_name_operator").attributes[ - "method" - ] - == "keypad" - ) + + state = hass.states.get("sensor.online_with_doorsense_name_operator") + assert state.state == "Your favorite elven princess" + assert state.attributes["manual"] is False + assert state.attributes["tag"] is False + assert state.attributes["remote"] is False + assert state.attributes["keypad"] is True + assert state.attributes["autorelock"] is False + assert state.attributes["method"] == "keypad" async def test_lock_operator_remote(hass: HomeAssistant) -> None: @@ -225,34 +197,39 @@ async def test_lock_operator_remote(hass: HomeAssistant) -> None: "sensor.online_with_doorsense_name_operator" ) assert lock_operator_sensor - assert ( - hass.states.get("sensor.online_with_doorsense_name_operator").state - == "Your favorite elven princess" + + state = hass.states.get("sensor.online_with_doorsense_name_operator") + assert state.state == "Your favorite elven princess" + assert state.attributes["manual"] is False + assert state.attributes["tag"] is False + assert state.attributes["remote"] is True + assert state.attributes["keypad"] is False + assert state.attributes["autorelock"] is False + assert state.attributes["method"] == "remote" + + +async def test_lock_operator_manual(hass: HomeAssistant) -> None: + """Test operation of a lock with doorsense and bridge.""" + lock_one = await _mock_doorsense_enabled_august_lock_detail(hass) + + activities = await _mock_activities_from_fixture( + hass, "get_activity.lock_from_manual.json" ) - assert ( - hass.states.get("sensor.online_with_doorsense_name_operator").attributes[ - "remote" - ] - is True - ) - assert ( - hass.states.get("sensor.online_with_doorsense_name_operator").attributes[ - "keypad" - ] - is False - ) - assert ( - hass.states.get("sensor.online_with_doorsense_name_operator").attributes[ - "autorelock" - ] - is False - ) - assert ( - hass.states.get("sensor.online_with_doorsense_name_operator").attributes[ - "method" - ] - == "remote" + await _create_august_with_devices(hass, [lock_one], activities=activities) + + entity_registry = er.async_get(hass) + lock_operator_sensor = entity_registry.async_get( + "sensor.online_with_doorsense_name_operator" ) + assert lock_operator_sensor + state = hass.states.get("sensor.online_with_doorsense_name_operator") + assert state.state == "Your favorite elven princess" + assert state.attributes["manual"] is True + assert state.attributes["tag"] is False + assert state.attributes["remote"] is False + assert state.attributes["keypad"] is False + assert state.attributes["autorelock"] is False + assert state.attributes["method"] == "manual" async def test_lock_operator_autorelock(hass: HomeAssistant) -> None: @@ -269,31 +246,110 @@ async def test_lock_operator_autorelock(hass: HomeAssistant) -> None: "sensor.online_with_doorsense_name_operator" ) assert lock_operator_sensor - assert ( - hass.states.get("sensor.online_with_doorsense_name_operator").state - == "Auto Relock" + + state = hass.states.get("sensor.online_with_doorsense_name_operator") + assert state.state == "Auto Relock" + assert state.attributes["manual"] is False + assert state.attributes["tag"] is False + assert state.attributes["remote"] is False + assert state.attributes["keypad"] is False + assert state.attributes["autorelock"] is True + assert state.attributes["method"] == "autorelock" + + +async def test_unlock_operator_manual(hass: HomeAssistant) -> None: + """Test operation of a lock manually.""" + lock_one = await _mock_doorsense_enabled_august_lock_detail(hass) + + activities = await _mock_activities_from_fixture( + hass, "get_activity.unlock_from_manual.json" ) - assert ( - hass.states.get("sensor.online_with_doorsense_name_operator").attributes[ - "remote" - ] - is False + await _create_august_with_devices(hass, [lock_one], activities=activities) + + entity_registry = er.async_get(hass) + lock_operator_sensor = entity_registry.async_get( + "sensor.online_with_doorsense_name_operator" ) - assert ( - hass.states.get("sensor.online_with_doorsense_name_operator").attributes[ - "keypad" - ] - is False + assert lock_operator_sensor + + state = hass.states.get("sensor.online_with_doorsense_name_operator") + assert state.state == "Your favorite elven princess" + assert state.attributes["manual"] is True + assert state.attributes["tag"] is False + assert state.attributes["remote"] is False + assert state.attributes["keypad"] is False + assert state.attributes["autorelock"] is False + assert state.attributes["method"] == "manual" + + +async def test_unlock_operator_tag(hass: HomeAssistant) -> None: + """Test operation of a lock with a tag.""" + lock_one = await _mock_doorsense_enabled_august_lock_detail(hass) + + activities = await _mock_activities_from_fixture( + hass, "get_activity.unlock_from_tag.json" ) - assert ( - hass.states.get("sensor.online_with_doorsense_name_operator").attributes[ - "autorelock" - ] - is True + await _create_august_with_devices(hass, [lock_one], activities=activities) + + entity_registry = er.async_get(hass) + lock_operator_sensor = entity_registry.async_get( + "sensor.online_with_doorsense_name_operator" ) - assert ( - hass.states.get("sensor.online_with_doorsense_name_operator").attributes[ - "method" - ] - == "autorelock" + assert lock_operator_sensor + + state = hass.states.get("sensor.online_with_doorsense_name_operator") + assert state.state == "Your favorite elven princess" + assert state.attributes["manual"] is False + assert state.attributes["tag"] is True + assert state.attributes["remote"] is False + assert state.attributes["keypad"] is False + assert state.attributes["autorelock"] is False + assert state.attributes["method"] == "tag" + + +async def test_restored_state( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """Test restored state.""" + + entity_id = "sensor.online_with_doorsense_name_operator" + lock_one = await _mock_doorsense_enabled_august_lock_detail(hass) + + fake_state = ha.State( + entity_id, + state="Tag Unlock", + attributes={ + "method": "tag", + "manual": False, + "remote": False, + "keypad": False, + "tag": True, + "autorelock": False, + ATTR_ENTITY_PICTURE: "image.png", + }, ) + + # Home assistant is not running yet + hass.state = CoreState.not_running + last_reset = "2023-09-22T00:00:00.000000+00:00" + mock_restore_cache_with_extra_data( + hass, + [ + ( + fake_state, + { + "last_reset": last_reset, + }, + ) + ], + ) + + august_entry = await _create_august_with_devices(hass, [lock_one]) + august_entry.add_to_hass(hass) + + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == "Tag Unlock" + assert state.attributes["method"] == "tag" + assert state.attributes[ATTR_ENTITY_PICTURE] == "image.png" diff --git a/tests/components/aurora/test_config_flow.py b/tests/components/aurora/test_config_flow.py index 401ee37382e..ebd7780900a 100644 --- a/tests/components/aurora/test_config_flow.py +++ b/tests/components/aurora/test_config_flow.py @@ -10,7 +10,6 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry DATA = { - "name": "Home", "latitude": -10, "longitude": 10.2, } @@ -39,7 +38,7 @@ async def test_form(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert result2["type"] == "create_entry" - assert result2["title"] == "Aurora - Home" + assert result2["title"] == "Aurora visibility" assert result2["data"] == DATA assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/aussie_broadband/test_init.py b/tests/components/aussie_broadband/test_init.py index dc32212ee87..3eb1972011c 100644 --- a/tests/components/aussie_broadband/test_init.py +++ b/tests/components/aussie_broadband/test_init.py @@ -3,11 +3,8 @@ from unittest.mock import patch from aiohttp import ClientConnectionError from aussiebb.exceptions import AuthenticationException, UnrecognisedServiceType -import pydantic -import pytest from homeassistant import data_entry_flow -from homeassistant.components.aussie_broadband import validate_service_type from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -22,19 +19,6 @@ async def test_unload(hass: HomeAssistant) -> None: assert entry.state is ConfigEntryState.NOT_LOADED -async def test_validate_service_type() -> None: - """Testing the validation function.""" - test_service = {"type": "Hardware", "name": "test service"} - validate_service_type(test_service) - - with pytest.raises(ValueError): - test_service = {"name": "test service"} - validate_service_type(test_service) - with pytest.raises(UnrecognisedServiceType): - test_service = {"type": "FunkyBob", "name": "test service"} - validate_service_type(test_service) - - async def test_auth_failure(hass: HomeAssistant) -> None: """Test init with an authentication failure.""" with patch( @@ -55,9 +39,3 @@ async def test_service_failure(hass: HomeAssistant) -> None: """Test init with a invalid service.""" entry = await setup_platform(hass, usage_effect=UnrecognisedServiceType()) assert entry.state is ConfigEntryState.SETUP_RETRY - - -async def test_not_pydantic2() -> None: - """Test that Home Assistant still does not support Pydantic 2.""" - """For PR#99077 and validate_service_type backport""" - assert pydantic.__version__ < "2" diff --git a/tests/components/awair/const.py b/tests/components/awair/const.py index cead20d10af..f24eaeb971d 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 @@ LOCAL_CONFIG = {CONF_HOST: "192.0.2.5"} CLOUD_UNIQUE_ID = "foo@bar.com" LOCAL_UNIQUE_ID = "00:B0:D0:63:C2:26" ZEROCONF_DISCOVERY = zeroconf.ZeroconfServiceInfo( - 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 d535b4bcb1f..06fad5329ea 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 ef2cc7f448a..ff7ff343a06 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 871e75f7c23..f770db05096 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 0f2cfebd12e..765f7af3f62 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 83ad809016a..fba86223a2d 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 test_basic_usage( # 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 test_poll_can_be_skipped( 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 test_bleak_error_and_recover( # 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 test_poll_failure_and_recover( # 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 test_no_polling_after_stop_event( # 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_advertisement_tracker.py b/tests/components/bluetooth/test_advertisement_tracker.py index 5a2c55259bb..f04ea2873f0 100644 --- a/tests/components/bluetooth/test_advertisement_tracker.py +++ b/tests/components/bluetooth/test_advertisement_tracker.py @@ -6,6 +6,7 @@ from unittest.mock import patch import pytest from homeassistant.components.bluetooth import ( + async_get_learned_advertising_interval, async_register_scanner, async_track_unavailable, ) @@ -62,6 +63,10 @@ async def test_advertisment_interval_shorter_than_adapter_stack_timeout( SOURCE_LOCAL, ) + assert async_get_learned_advertising_interval( + hass, "44:44:33:11:23:12" + ) == pytest.approx(2.0) + switchbot_device_unavailable_cancel = async_track_unavailable( hass, _switchbot_device_unavailable_callback, switchbot_device.address ) @@ -109,6 +114,10 @@ async def test_advertisment_interval_longer_than_adapter_stack_timeout_connectab SOURCE_LOCAL, ) + assert async_get_learned_advertising_interval( + hass, "44:44:33:11:23:18" + ) == pytest.approx(ONE_HOUR_SECONDS) + switchbot_device_unavailable_cancel = async_track_unavailable( hass, _switchbot_device_unavailable_callback, switchbot_device.address ) @@ -158,6 +167,10 @@ async def test_advertisment_interval_longer_than_adapter_stack_timeout_adapter_c "original", ) + assert async_get_learned_advertising_interval( + hass, "44:44:33:11:23:45" + ) == pytest.approx(2.0) + for i in range(ADVERTISING_TIMES_NEEDED): inject_advertisement_with_time_and_source( hass, @@ -167,6 +180,10 @@ async def test_advertisment_interval_longer_than_adapter_stack_timeout_adapter_c "new", ) + assert async_get_learned_advertising_interval( + hass, "44:44:33:11:23:45" + ) == pytest.approx(ONE_HOUR_SECONDS) + switchbot_device_unavailable_cancel = async_track_unavailable( hass, _switchbot_device_unavailable_callback, switchbot_device.address ) @@ -216,6 +233,10 @@ async def test_advertisment_interval_longer_than_adapter_stack_timeout_not_conne SOURCE_LOCAL, ) + assert async_get_learned_advertising_interval( + hass, "44:44:33:11:23:45" + ) == pytest.approx(ONE_HOUR_SECONDS) + switchbot_device_unavailable_cancel = async_track_unavailable( hass, _switchbot_device_unavailable_callback, @@ -270,6 +291,10 @@ async def test_advertisment_interval_shorter_than_adapter_stack_timeout_adapter_ "original", ) + assert async_get_learned_advertising_interval( + hass, "44:44:33:11:23:5C" + ) == pytest.approx(ONE_HOUR_SECONDS) + switchbot_adv_better_rssi = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], @@ -284,6 +309,10 @@ async def test_advertisment_interval_shorter_than_adapter_stack_timeout_adapter_ "new", ) + assert async_get_learned_advertising_interval( + hass, "44:44:33:11:23:5C" + ) == pytest.approx(2.0) + switchbot_device_unavailable_cancel = async_track_unavailable( hass, _switchbot_device_unavailable_callback, @@ -342,6 +371,10 @@ async def test_advertisment_interval_longer_than_adapter_stack_timeout_adapter_c connectable=False, ) + assert async_get_learned_advertising_interval( + hass, "44:44:33:11:23:45" + ) == pytest.approx(2.0) + switchbot_better_rssi_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], @@ -357,6 +390,10 @@ async def test_advertisment_interval_longer_than_adapter_stack_timeout_adapter_c connectable=False, ) + assert async_get_learned_advertising_interval( + hass, "44:44:33:11:23:45" + ) == pytest.approx(ONE_HOUR_SECONDS) + switchbot_device_unavailable_cancel = async_track_unavailable( hass, _switchbot_device_unavailable_callback, @@ -437,6 +474,10 @@ async def test_advertisment_interval_longer_increasing_than_adapter_stack_timeou "new", ) + assert async_get_learned_advertising_interval( + hass, "44:44:33:11:23:45" + ) == pytest.approx(61.0) + switchbot_device_unavailable_cancel = async_track_unavailable( hass, _switchbot_device_unavailable_callback, diff --git a/tests/components/bluetooth/test_base_scanner.py b/tests/components/bluetooth/test_base_scanner.py index 5662bc6324b..fc870f2bfe3 100644 --- a/tests/components/bluetooth/test_base_scanner.py +++ b/tests/components/bluetooth/test_base_scanner.py @@ -23,6 +23,8 @@ from homeassistant.components.bluetooth.advertisement_tracker import ( from homeassistant.components.bluetooth.const import ( CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, + SCANNER_WATCHDOG_INTERVAL, + SCANNER_WATCHDOG_TIMEOUT, UNAVAILABLE_TRACK_SECONDS, ) from homeassistant.core import HomeAssistant, callback @@ -557,3 +559,82 @@ async def test_device_with_ten_minute_advertising_interval( cancel() unsetup() + + +async def test_scanner_stops_responding( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, enable_bluetooth: None +) -> None: + """Test we mark a scanner are not scanning when it stops responding.""" + manager = _get_manager() + + class FakeScanner(BaseHaRemoteScanner): + """A fake remote scanner.""" + + def inject_advertisement( + self, device: BLEDevice, advertisement_data: AdvertisementData + ) -> None: + """Inject an advertisement.""" + self._async_on_advertisement( + device.address, + advertisement_data.rssi, + device.name, + advertisement_data.service_uuids, + advertisement_data.service_data, + advertisement_data.manufacturer_data, + advertisement_data.tx_power, + {"scanner_specific_data": "test"}, + MONOTONIC_TIME(), + ) + + new_info_callback = manager.scanner_adv_received + connector = ( + HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), + ) + scanner = FakeScanner(hass, "esp32", "esp32", new_info_callback, connector, False) + unsetup = scanner.async_setup() + cancel = manager.async_register_scanner(scanner, True) + + start_time_monotonic = time.monotonic() + + assert scanner.scanning is True + failure_reached_time = ( + start_time_monotonic + + SCANNER_WATCHDOG_TIMEOUT + + SCANNER_WATCHDOG_INTERVAL.total_seconds() + ) + # We hit the timer with no detections, so we reset the adapter and restart the scanner + with patch( + "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", + return_value=failure_reached_time, + ): + async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) + await hass.async_block_till_done() + + assert scanner.scanning is False + + bparasite_device = generate_ble_device( + "44:44:33:11:23:45", + "bparasite", + {}, + rssi=-100, + ) + bparasite_device_adv = generate_advertisement_data( + local_name="bparasite", + service_uuids=[], + manufacturer_data={1: b"\x01"}, + rssi=-100, + ) + + failure_reached_time += 1 + + with patch( + "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", + return_value=failure_reached_time, + ): + scanner.inject_advertisement(bparasite_device, bparasite_device_adv) + + # As soon as we get a detection, we know the scanner is working again + assert scanner.scanning is True + + cancel() + unsetup() diff --git a/tests/components/bluetooth/test_manager.py b/tests/components/bluetooth/test_manager.py index f637ee3a27a..6c89074e471 100644 --- a/tests/components/bluetooth/test_manager.py +++ b/tests/components/bluetooth/test_manager.py @@ -20,11 +20,17 @@ from homeassistant.components.bluetooth import ( HaBluetoothConnector, async_ble_device_from_address, async_get_advertisement_callback, + async_get_fallback_availability_interval, + async_get_learned_advertising_interval, async_scanner_count, + async_set_fallback_availability_interval, async_track_unavailable, storage, ) -from homeassistant.components.bluetooth.const import UNAVAILABLE_TRACK_SECONDS +from homeassistant.components.bluetooth.const import ( + SOURCE_LOCAL, + UNAVAILABLE_TRACK_SECONDS, +) from homeassistant.components.bluetooth.manager import ( FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, ) @@ -1005,6 +1011,7 @@ async def test_debug_logging( caplog: pytest.LogCaptureFixture, ) -> None: """Test debug logging.""" + assert await async_setup_component(hass, "logger", {"logger": {}}) await hass.services.async_call( "logger", "set_level", @@ -1053,3 +1060,142 @@ async def test_debug_logging( "hci0", ) assert "wohand_good_signal_hci0" not in caplog.text + + +async def test_set_fallback_interval_small( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + enable_bluetooth: None, + macos_adapter: None, +) -> None: + """Test we can set the fallback advertisement interval.""" + assert async_get_fallback_availability_interval(hass, "44:44:33:11:23:12") is None + + async_set_fallback_availability_interval(hass, "44:44:33:11:23:12", 2.0) + assert async_get_fallback_availability_interval(hass, "44:44:33:11:23:12") == 2.0 + + start_monotonic_time = time.monotonic() + switchbot_device = generate_ble_device("44:44:33:11:23:12", "wohand") + switchbot_adv = generate_advertisement_data( + local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] + ) + switchbot_device_went_unavailable = False + + inject_advertisement_with_time_and_source( + hass, + switchbot_device, + switchbot_adv, + start_monotonic_time, + SOURCE_LOCAL, + ) + + @callback + def _switchbot_device_unavailable_callback(_address: str) -> None: + """Switchbot device unavailable callback.""" + nonlocal switchbot_device_went_unavailable + switchbot_device_went_unavailable = True + + assert async_get_learned_advertising_interval(hass, "44:44:33:11:23:12") is None + + switchbot_device_unavailable_cancel = async_track_unavailable( + hass, + _switchbot_device_unavailable_callback, + switchbot_device.address, + connectable=False, + ) + + monotonic_now = start_monotonic_time + 2 + with patch( + "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", + return_value=monotonic_now + UNAVAILABLE_TRACK_SECONDS, + ): + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) + ) + await hass.async_block_till_done() + + assert switchbot_device_went_unavailable is True + switchbot_device_unavailable_cancel() + + # We should forget fallback interval after it expires + assert async_get_fallback_availability_interval(hass, "44:44:33:11:23:12") is None + + +async def test_set_fallback_interval_big( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + enable_bluetooth: None, + macos_adapter: None, +) -> None: + """Test we can set the fallback advertisement interval.""" + assert async_get_fallback_availability_interval(hass, "44:44:33:11:23:12") is None + + # Force the interval to be really big and check it doesn't expire using the default timeout (900) + + async_set_fallback_availability_interval(hass, "44:44:33:11:23:12", 604800.0) + assert ( + async_get_fallback_availability_interval(hass, "44:44:33:11:23:12") == 604800.0 + ) + + start_monotonic_time = time.monotonic() + switchbot_device = generate_ble_device("44:44:33:11:23:12", "wohand") + switchbot_adv = generate_advertisement_data( + local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] + ) + switchbot_device_went_unavailable = False + + inject_advertisement_with_time_and_source( + hass, + switchbot_device, + switchbot_adv, + start_monotonic_time, + SOURCE_LOCAL, + ) + + @callback + def _switchbot_device_unavailable_callback(_address: str) -> None: + """Switchbot device unavailable callback.""" + nonlocal switchbot_device_went_unavailable + switchbot_device_went_unavailable = True + + assert async_get_learned_advertising_interval(hass, "44:44:33:11:23:12") is None + + switchbot_device_unavailable_cancel = async_track_unavailable( + hass, + _switchbot_device_unavailable_callback, + switchbot_device.address, + connectable=False, + ) + + # Check that device hasn't expired after a day + + monotonic_now = start_monotonic_time + 86400 + with patch( + "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", + return_value=monotonic_now + UNAVAILABLE_TRACK_SECONDS, + ): + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) + ) + await hass.async_block_till_done() + + assert switchbot_device_went_unavailable is False + + # Try again after it has expired + + monotonic_now = start_monotonic_time + 604800 + with patch( + "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", + return_value=monotonic_now + UNAVAILABLE_TRACK_SECONDS, + ): + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) + ) + await hass.async_block_till_done() + + assert switchbot_device_went_unavailable is True + + switchbot_device_unavailable_cancel() + + # We should forget fallback interval after it expires + assert async_get_fallback_availability_interval(hass, "44:44:33:11:23:12") is None diff --git a/tests/components/bluetooth/test_passive_update_processor.py b/tests/components/bluetooth/test_passive_update_processor.py index c96fbfbfc99..5baff65f29a 100644 --- a/tests/components/bluetooth/test_passive_update_processor.py +++ b/tests/components/bluetooth/test_passive_update_processor.py @@ -858,22 +858,49 @@ async def test_integration_with_entity( 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 @@ async def test_integration_with_entity( 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 00000000000..d64bdb32597 --- /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 95b1145d9d6..c6cb12cf047 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 homeassistant.util.unit_system import ( 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 fab579a81a3..91d628e4841 100644 --- a/tests/components/bond/test_config_flow.py +++ b/tests/components/bond/test_config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations 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 92f49b86ef7..e5d0abb3c9d 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 @@ MOCK_SETTINGS = { "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 629295e09e0..f83f882b8a0 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 52985da0014..f950fce6a68 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 29fbf372ec4..6c1d593560e 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 e6a526c7c9e..48421f5c41f 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/co2signal/test_config_flow.py b/tests/components/co2signal/test_config_flow.py index ddd2049800a..879293ae959 100644 --- a/tests/components/co2signal/test_config_flow.py +++ b/tests/components/co2signal/test_config_flow.py @@ -1,5 +1,6 @@ """Test the CO2 Signal config flow.""" -from unittest.mock import patch +from json import JSONDecodeError +from unittest.mock import Mock, patch import pytest @@ -131,14 +132,33 @@ async def test_form_country(hass: HomeAssistant) -> None: @pytest.mark.parametrize( - ("err_str", "err_code"), + ("side_effect", "err_code"), [ - ("Invalid authentication credentials", "invalid_auth"), - ("API rate limit exceeded.", "api_ratelimit"), - ("Something else", "unknown"), + ( + ValueError("Invalid authentication credentials"), + "invalid_auth", + ), + ( + ValueError("API rate limit exceeded."), + "api_ratelimit", + ), + (ValueError("Something else"), "unknown"), + (JSONDecodeError(msg="boom", doc="", pos=1), "unknown"), + (Exception("Boom"), "unknown"), + (Mock(return_value={"error": "boom"}), "unknown"), + (Mock(return_value={"status": "error"}), "unknown"), + ], + ids=[ + "invalid auth", + "rate limit exceeded", + "unknown value error", + "json decode error", + "unknown error", + "error in json dict", + "status error", ], ) -async def test_form_error_handling(hass: HomeAssistant, err_str, err_code) -> None: +async def test_form_error_handling(hass: HomeAssistant, side_effect, err_code) -> None: """Test we handle expected errors.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -146,9 +166,9 @@ async def test_form_error_handling(hass: HomeAssistant, err_str, err_code) -> No with patch( "CO2Signal.get_latest", - side_effect=ValueError(err_str), + side_effect=side_effect, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { "location": config_flow.TYPE_USE_HOME, @@ -156,49 +176,24 @@ async def test_form_error_handling(hass: HomeAssistant, err_str, err_code) -> No }, ) - assert result2["type"] == FlowResultType.FORM - assert result2["errors"] == {"base": err_code} - - -async def test_form_error_unexpected_error(hass: HomeAssistant) -> None: - """Test we handle unexpected error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": err_code} with patch( "CO2Signal.get_latest", - side_effect=Exception("Boom"), + return_value=VALID_PAYLOAD, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { "location": config_flow.TYPE_USE_HOME, "api_key": "api_key", }, ) + await hass.async_block_till_done() - assert result2["type"] == FlowResultType.FORM - assert result2["errors"] == {"base": "unknown"} - - -async def test_form_error_unexpected_data(hass: HomeAssistant) -> None: - """Test we handle unexpected data.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch( - "CO2Signal.get_latest", - return_value={"status": "error"}, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "location": config_flow.TYPE_USE_HOME, - "api_key": "api_key", - }, - ) - - assert result2["type"] == FlowResultType.FORM - assert result2["errors"] == {"base": "unknown"} + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "CO2 Signal" + assert result["data"] == { + "api_key": "api_key", + } diff --git a/tests/components/color_extractor/test_config_flow.py b/tests/components/color_extractor/test_config_flow.py new file mode 100644 index 00000000000..9dc928da73f --- /dev/null +++ b/tests/components/color_extractor/test_config_flow.py @@ -0,0 +1,70 @@ +"""Tests for the Color extractor config flow.""" +from unittest.mock import patch + +import pytest + +from homeassistant.components.color_extractor.const import DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_full_user_flow(hass: HomeAssistant) -> None: + """Test the full user configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result.get("type") == FlowResultType.FORM + assert result.get("step_id") == "user" + + with patch( + "homeassistant.components.color_extractor.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + + assert result.get("type") == FlowResultType.CREATE_ENTRY + assert result.get("title") == "Color extractor" + assert result.get("data") == {} + assert result.get("options") == {} + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize("source", [SOURCE_USER, SOURCE_IMPORT]) +async def test_single_instance_allowed( + hass: HomeAssistant, + source: str, +) -> None: + """Test we abort if already setup.""" + mock_config_entry = MockConfigEntry(domain=DOMAIN) + + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": source}, data={} + ) + + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "single_instance_allowed" + + +async def test_import_flow( + hass: HomeAssistant, +) -> None: + """Test the import configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={}, + ) + + assert result.get("type") == FlowResultType.CREATE_ENTRY + assert result.get("title") == "Color extractor" + assert result.get("data") == {} + assert result.get("options") == {} diff --git a/tests/components/color_extractor/test_service.py b/tests/components/color_extractor/test_service.py index 31218387858..ae3e799e9d2 100644 --- a/tests/components/color_extractor/test_service.py +++ b/tests/components/color_extractor/test_service.py @@ -63,7 +63,7 @@ def _close_enough(actual_rgb, testing_rgb): @pytest.fixture(autouse=True) -async def setup_light(hass): +async def setup_light(hass: HomeAssistant): """Configure our light component to work against for testing.""" assert await async_setup_component( hass, LIGHT_DOMAIN, {LIGHT_DOMAIN: {"platform": "demo"}} diff --git a/tests/components/daikin/test_config_flow.py b/tests/components/daikin/test_config_flow.py index 27c3b7d9ea3..4d54d7483df 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/daikin/test_init.py b/tests/components/daikin/test_init.py index a6a58b4fb39..3b5f81ae2e5 100644 --- a/tests/components/daikin/test_init.py +++ b/tests/components/daikin/test_init.py @@ -1,11 +1,13 @@ """Define tests for the Daikin init.""" import asyncio +from datetime import timedelta from unittest.mock import AsyncMock, PropertyMock, patch from aiohttp import ClientConnectionError +from freezegun.api import FrozenDateTimeFactory import pytest -from homeassistant.components.daikin import update_unique_id +from homeassistant.components.daikin import DaikinApi, update_unique_id from homeassistant.components.daikin.const import DOMAIN, KEY_MAC from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_HOST @@ -14,7 +16,7 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from .test_config_flow import HOST, MAC -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed @pytest.fixture @@ -28,6 +30,7 @@ def mock_daikin(): with patch("homeassistant.components.daikin.Appliance") as Appliance: Appliance.factory.side_effect = mock_daikin_factory type(Appliance).update_status = AsyncMock() + type(Appliance).device_ip = PropertyMock(return_value=HOST) type(Appliance).inside_temperature = PropertyMock(return_value=22) type(Appliance).target_temperature = PropertyMock(return_value=22) type(Appliance).zones = PropertyMock(return_value=[("Zone 1", "0", 0)]) @@ -47,6 +50,67 @@ DATA = { INVALID_DATA = {**DATA, "name": None, "mac": HOST} +async def test_duplicate_removal(hass: HomeAssistant, mock_daikin) -> None: + """Test duplicate device removal.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=HOST, + title=None, + data={CONF_HOST: HOST, KEY_MAC: HOST}, + ) + config_entry.add_to_hass(hass) + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + + type(mock_daikin).mac = PropertyMock(return_value=HOST) + type(mock_daikin).values = PropertyMock(return_value=INVALID_DATA) + + with patch( + "homeassistant.components.daikin.async_migrate_unique_id", return_value=None + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + + await hass.async_block_till_done() + + assert config_entry.unique_id != MAC + + type(mock_daikin).mac = PropertyMock(return_value=MAC) + type(mock_daikin).values = PropertyMock(return_value=DATA) + + assert await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() + + assert ( + device_registry.async_get_device({}, {(KEY_MAC, MAC)}).name + == "DaikinAP00000" + ) + + assert device_registry.async_get_device({}, {(KEY_MAC, HOST)}).name is None + + assert entity_registry.async_get("climate.daikin_127_0_0_1").unique_id == HOST + assert entity_registry.async_get("switch.none_zone_1").unique_id.startswith( + HOST + ) + + assert entity_registry.async_get("climate.daikinap00000").unique_id == MAC + assert entity_registry.async_get( + "switch.daikinap00000_zone_1" + ).unique_id.startswith(MAC) + + assert await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() + + assert ( + device_registry.async_get_device({}, {(KEY_MAC, MAC)}).name == "DaikinAP00000" + ) + + assert entity_registry.async_get("climate.daikinap00000") is None + assert entity_registry.async_get("switch.daikinap00000_zone_1") is None + + assert entity_registry.async_get("climate.daikin_127_0_0_1").unique_id == MAC + assert entity_registry.async_get("switch.none_zone_1").unique_id.startswith(MAC) + + async def test_unique_id_migrate(hass: HomeAssistant, mock_daikin) -> None: """Test unique id migration.""" config_entry = MockConfigEntry( @@ -97,8 +161,41 @@ async def test_unique_id_migrate(hass: HomeAssistant, mock_daikin) -> None: assert entity_registry.async_get("switch.none_zone_1").unique_id.startswith(MAC) +async def test_client_update_connection_error( + hass: HomeAssistant, mock_daikin, freezer: FrozenDateTimeFactory +) -> None: + """Test client connection error on update.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=MAC, + data={CONF_HOST: HOST, KEY_MAC: MAC}, + ) + config_entry.add_to_hass(hass) + er.async_get(hass) + + type(mock_daikin).mac = PropertyMock(return_value=MAC) + type(mock_daikin).values = PropertyMock(return_value=DATA) + + await hass.config_entries.async_setup(config_entry.entry_id) + + api: DaikinApi = hass.data[DOMAIN][config_entry.entry_id] + + assert api.available is True + + type(mock_daikin).update_status.side_effect = ClientConnectionError + + freezer.tick(timedelta(seconds=90)) + async_fire_time_changed(hass) + + await hass.async_block_till_done() + + assert api.available is False + + assert mock_daikin.update_status.call_count == 2 + + async def test_client_connection_error(hass: HomeAssistant, mock_daikin) -> None: - """Test unique id migration.""" + """Test client connection error on setup.""" config_entry = MockConfigEntry( domain=DOMAIN, unique_id=MAC, @@ -114,7 +211,7 @@ async def test_client_connection_error(hass: HomeAssistant, mock_daikin) -> None async def test_timeout_error(hass: HomeAssistant, mock_daikin) -> None: - """Test unique id migration.""" + """Test timeout error on setup.""" config_entry = MockConfigEntry( domain=DOMAIN, unique_id=MAC, diff --git a/tests/components/devolo_home_control/const.py b/tests/components/devolo_home_control/const.py index 96090195d20..3351e42c988 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 = zeroconf.ZeroconfServiceInfo( ) 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_DEVOLO_DEVICE = zeroconf.ZeroconfServiceInfo( ) 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 bc2ef2d87b2..8cf63cf07ae 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 @@ CONNECTED_STATIONS = [ 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 = ZeroconfServiceInfo( ) 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_CHANGED = ZeroconfServiceInfo( ) 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/devolo_home_network/test_update.py b/tests/components/devolo_home_network/test_update.py index 97d313d9273..cb6de649e8e 100644 --- a/tests/components/devolo_home_network/test_update.py +++ b/tests/components/devolo_home_network/test_update.py @@ -6,7 +6,7 @@ import pytest from homeassistant.components.devolo_home_network.const import ( DOMAIN, - LONG_UPDATE_INTERVAL, + FIRMWARE_UPDATE_INTERVAL, ) from homeassistant.components.update import ( DOMAIN as PLATFORM, @@ -78,7 +78,7 @@ async def test_update_firmware( mock_device.device.async_check_firmware_available.return_value = ( UpdateFirmwareCheck(result=UPDATE_NOT_AVAILABLE) ) - freezer.tick(LONG_UPDATE_INTERVAL) + freezer.tick(FIRMWARE_UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -106,7 +106,7 @@ async def test_device_failure_check( assert state is not None mock_device.device.async_check_firmware_available.side_effect = DeviceUnavailable - freezer.tick(LONG_UPDATE_INTERVAL) + freezer.tick(FIRMWARE_UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() diff --git a/tests/components/discovergy/test_config_flow.py b/tests/components/discovergy/test_config_flow.py index bc4fd2d9e9d..08e9df06978 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.const import CONF_EMAIL, CONF_PASSWORD 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,17 +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 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_EMAIL: "test@example.com", + CONF_PASSWORD: "test-password", + }, + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": message} + + 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", @@ -91,43 +113,6 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None: }, ) - 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"], - { - CONF_EMAIL: "test@example.com", - CONF_PASSWORD: "test-password", - }, - ) - - assert result2["type"] == data_entry_flow.FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} - - -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( - result["flow_id"], - { - CONF_EMAIL: "test@example.com", - CONF_PASSWORD: "test-password", - }, - ) - - 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 e982f4ca172..7ad7fbe07ac 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 00000000000..031cba659d2 --- /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 00000000000..09860546c15 --- /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 00000000000..302cbe76fa9 --- /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 525f5742382..f7e60e975f8 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.components.electric_kiwi.const import DOMAIN 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 00000000000..d29825391e9 --- /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 00000000000..15ecc174f13 --- /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 51d00722341..1199c3e555a 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.config_entries import SOURCE_REAUTH 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 @@ from tests.typing import ClientSessionGenerator 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 00000000000..ef268735334 --- /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 1b71a29632f..bfae6fc9a17 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 a4481f4ed51..25517e390ca 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 63e18107623..01ba07852d6 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/esphome/test_voice_assistant.py b/tests/components/esphome/test_voice_assistant.py index b7ce5670441..9b6bcf1c6c7 100644 --- a/tests/components/esphome/test_voice_assistant.py +++ b/tests/components/esphome/test_voice_assistant.py @@ -10,10 +10,12 @@ import pytest from homeassistant.components.assist_pipeline import ( PipelineEvent, PipelineEventType, - PipelineNotFound, PipelineStage, ) -from homeassistant.components.assist_pipeline.error import WakeWordDetectionError +from homeassistant.components.assist_pipeline.error import ( + WakeWordDetectionAborted, + WakeWordDetectionError, +) from homeassistant.components.esphome import DomainData from homeassistant.components.esphome.voice_assistant import VoiceAssistantUDPServer from homeassistant.core import HomeAssistant @@ -370,6 +372,8 @@ async def test_wake_word( with patch( "homeassistant.components.esphome.voice_assistant.async_pipeline_from_audio_stream", new=async_pipeline_from_audio_stream, + ), patch( + "asyncio.Event.wait" # TTS wait event ): voice_assistant_udp_server_v2.transport = Mock() @@ -377,7 +381,6 @@ async def test_wake_word( device_id="mock-device-id", conversation_id=None, flags=2, - pipeline_timeout=1, ) @@ -410,38 +413,28 @@ async def test_wake_word_exception( device_id="mock-device-id", conversation_id=None, flags=2, - pipeline_timeout=1, ) -async def test_pipeline_timeout( +async def test_wake_word_abort_exception( hass: HomeAssistant, voice_assistant_udp_server_v2: VoiceAssistantUDPServer, ) -> None: """Test that the pipeline is set to start with Wake word.""" async def async_pipeline_from_audio_stream(*args, **kwargs): - raise PipelineNotFound("not-found", "Pipeline not found") + raise WakeWordDetectionAborted with patch( "homeassistant.components.esphome.voice_assistant.async_pipeline_from_audio_stream", new=async_pipeline_from_audio_stream, - ): + ), patch.object(voice_assistant_udp_server_v2, "handle_event") as mock_handle_event: voice_assistant_udp_server_v2.transport = Mock() - def handle_event( - event_type: VoiceAssistantEventType, data: dict[str, str] | None - ) -> None: - if event_type == VoiceAssistantEventType.VOICE_ASSISTANT_ERROR: - assert data is not None - assert data["code"] == "pipeline not found" - assert data["message"] == "Selected pipeline not found" - - voice_assistant_udp_server_v2.handle_event = handle_event - await voice_assistant_udp_server_v2.run_pipeline( device_id="mock-device-id", conversation_id=None, flags=2, - pipeline_timeout=1, ) + + mock_handle_event.assert_not_called() diff --git a/tests/components/faa_delays/test_config_flow.py b/tests/components/faa_delays/test_config_flow.py index 9eb166d5f69..5fb1b9cfcd2 100644 --- a/tests/components/faa_delays/test_config_flow.py +++ b/tests/components/faa_delays/test_config_flow.py @@ -15,7 +15,7 @@ from tests.common import MockConfigEntry async def mock_valid_airport(self, *args, **kwargs): """Return a valid airport.""" - self.name = "Test airport" + self.code = "test" async def test_form(hass: HomeAssistant) -> None: @@ -40,7 +40,7 @@ async def test_form(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert result2["type"] == "create_entry" - assert result2["title"] == "Test airport" + assert result2["title"] == "test" assert result2["data"] == { "id": "test", } @@ -61,27 +61,6 @@ async def test_duplicate_error(hass: HomeAssistant) -> None: assert result["reason"] == "already_configured" -async def test_form_invalid_airport(hass: HomeAssistant) -> None: - """Test we handle invalid airport.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch( - "faadelays.Airport.update", - side_effect=faadelays.InvalidAirport, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "id": "test", - }, - ) - - assert result2["type"] == "form" - assert result2["errors"] == {CONF_ID: "invalid_airport"} - - async def test_form_cannot_connect(hass: HomeAssistant) -> None: """Test we handle a connection error.""" result = await hass.config_entries.flow.async_init( diff --git a/tests/components/fibaro/conftest.py b/tests/components/fibaro/conftest.py index 8a2bbcbcd4a..2b6580c3191 100644 --- a/tests/components/fibaro/conftest.py +++ b/tests/components/fibaro/conftest.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, Mock, patch 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,9 @@ async def setup_platform( controller_mock = Mock() controller_mock.hub_serial = "HC2-111111" controller_mock.get_room_name.return_value = room_name + controller_mock.read_scenes.return_value = 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/fitbit/__init__.py b/tests/components/fitbit/__init__.py new file mode 100644 index 00000000000..0b639a3faa8 --- /dev/null +++ b/tests/components/fitbit/__init__.py @@ -0,0 +1 @@ +"""Tests for fitbit component.""" diff --git a/tests/components/fitbit/conftest.py b/tests/components/fitbit/conftest.py new file mode 100644 index 00000000000..7499a060933 --- /dev/null +++ b/tests/components/fitbit/conftest.py @@ -0,0 +1,182 @@ +"""Test fixtures for fitbit.""" + +from collections.abc import Awaitable, Callable, Generator +import datetime +from http import HTTPStatus +import time +from typing import Any +from unittest.mock import patch + +import pytest +from requests_mock.mocker import Mocker + +from homeassistant.components.fitbit.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +CLIENT_ID = "1234" +CLIENT_SECRET = "5678" +PROFILE_USER_ID = "fitbit-api-user-id-1" +FAKE_TOKEN = "some-token" +FAKE_REFRESH_TOKEN = "some-refresh-token" + +PROFILE_API_URL = "https://api.fitbit.com/1/user/-/profile.json" +DEVICES_API_URL = "https://api.fitbit.com/1/user/-/devices.json" +TIMESERIES_API_URL_FORMAT = ( + "https://api.fitbit.com/1/user/-/{resource}/date/today/7d.json" +) + + +@pytest.fixture(name="token_expiration_time") +def mcok_token_expiration_time() -> float: + """Fixture for expiration time of the config entry auth token.""" + return time.time() + 86400 + + +@pytest.fixture(name="fitbit_config_yaml") +def mock_fitbit_config_yaml(token_expiration_time: float) -> dict[str, Any]: + """Fixture for the yaml fitbit.conf file contents.""" + return { + "access_token": FAKE_TOKEN, + "refresh_token": FAKE_REFRESH_TOKEN, + "last_saved_at": token_expiration_time, + } + + +@pytest.fixture(name="fitbit_config_setup", autouse=True) +def mock_fitbit_config_setup( + fitbit_config_yaml: dict[str, Any], +) -> Generator[None, None, None]: + """Fixture to mock out fitbit.conf file data loading and persistence.""" + + with patch( + "homeassistant.components.fitbit.sensor.os.path.isfile", return_value=True + ), patch( + "homeassistant.components.fitbit.sensor.load_json_object", + return_value=fitbit_config_yaml, + ), patch( + "homeassistant.components.fitbit.sensor.save_json", + ): + yield + + +@pytest.fixture(name="monitored_resources") +def mock_monitored_resources() -> list[str] | None: + """Fixture for the fitbit yaml config monitored_resources field.""" + return None + + +@pytest.fixture(name="configured_unit_system") +def mock_configured_unit_syststem() -> str | None: + """Fixture for the fitbit yaml config monitored_resources field.""" + return None + + +@pytest.fixture(name="sensor_platform_config") +def mock_sensor_platform_config( + monitored_resources: list[str] | None, + configured_unit_system: str | None, +) -> dict[str, Any]: + """Fixture for the fitbit sensor platform configuration data in configuration.yaml.""" + config = {} + if monitored_resources is not None: + config["monitored_resources"] = monitored_resources + if configured_unit_system is not None: + config["unit_system"] = configured_unit_system + return config + + +@pytest.fixture(name="sensor_platform_setup") +async def mock_sensor_platform_setup( + hass: HomeAssistant, + sensor_platform_config: dict[str, Any], +) -> Callable[[], Awaitable[bool]]: + """Fixture to set up the integration.""" + + async def run() -> bool: + result = await async_setup_component( + hass, + "sensor", + { + "sensor": [ + { + "platform": DOMAIN, + **sensor_platform_config, + } + ] + }, + ) + await hass.async_block_till_done() + return result + + return run + + +@pytest.fixture(name="profile_id") +def mock_profile_id() -> str: + """Fixture for the profile id returned from the API response.""" + return PROFILE_USER_ID + + +@pytest.fixture(name="profile_locale") +def mock_profile_locale() -> str: + """Fixture to set the API response for the user profile.""" + return "en_US" + + +@pytest.fixture(name="profile", autouse=True) +def mock_profile(requests_mock: Mocker, profile_id: str, profile_locale: str) -> None: + """Fixture to setup fake requests made to Fitbit API during config flow.""" + requests_mock.register_uri( + "GET", + PROFILE_API_URL, + status_code=HTTPStatus.OK, + json={ + "user": { + "encodedId": profile_id, + "fullName": "My name", + "locale": profile_locale, + }, + }, + ) + + +@pytest.fixture(name="devices_response") +def mock_device_response() -> list[dict[str, Any]]: + """Return the list of devices.""" + return [] + + +@pytest.fixture(autouse=True) +def mock_devices(requests_mock: Mocker, devices_response: dict[str, Any]) -> None: + """Fixture to setup fake device responses.""" + requests_mock.register_uri( + "GET", + DEVICES_API_URL, + status_code=HTTPStatus.OK, + json=devices_response, + ) + + +def timeseries_response(resource: str, value: str) -> dict[str, Any]: + """Create a timeseries response value.""" + return { + resource: [{"dateTime": datetime.datetime.today().isoformat(), "value": value}] + } + + +@pytest.fixture(name="register_timeseries") +def mock_register_timeseries( + requests_mock: Mocker, +) -> Callable[[str, dict[str, Any]], None]: + """Fixture to setup fake timeseries API responses.""" + + def register(resource: str, response: dict[str, Any]) -> None: + requests_mock.register_uri( + "GET", + TIMESERIES_API_URL_FORMAT.format(resource=resource), + status_code=HTTPStatus.OK, + json=response, + ) + + return register diff --git a/tests/components/fitbit/snapshots/test_sensor.ambr b/tests/components/fitbit/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..719a2f8a6b8 --- /dev/null +++ b/tests/components/fitbit/snapshots/test_sensor.ambr @@ -0,0 +1,280 @@ +# serializer version: 1 +# name: test_sensors[monitored_resources0-sensor.activity_calories-activities/activityCalories-135] + tuple( + '135', + ReadOnlyDict({ + 'attribution': 'Data provided by Fitbit.com', + 'friendly_name': 'Activity Calories', + 'icon': 'mdi:fire', + 'unit_of_measurement': 'cal', + }), + 'fitbit-api-user-id-1_activities/activityCalories', + ) +# --- +# name: test_sensors[monitored_resources1-sensor.calories-activities/calories-139] + tuple( + '139', + ReadOnlyDict({ + 'attribution': 'Data provided by Fitbit.com', + 'friendly_name': 'Calories', + 'icon': 'mdi:fire', + 'unit_of_measurement': 'cal', + }), + 'fitbit-api-user-id-1_activities/calories', + ) +# --- +# name: test_sensors[monitored_resources10-sensor.steps-activities/steps-5600] + tuple( + '5600', + ReadOnlyDict({ + 'attribution': 'Data provided by Fitbit.com', + 'friendly_name': 'Steps', + 'icon': 'mdi:walk', + 'unit_of_measurement': 'steps', + }), + 'fitbit-api-user-id-1_activities/steps', + ) +# --- +# name: test_sensors[monitored_resources11-sensor.weight-body/weight-175] + tuple( + '175.0', + ReadOnlyDict({ + 'attribution': 'Data provided by Fitbit.com', + 'device_class': 'weight', + 'friendly_name': 'Weight', + 'icon': 'mdi:human', + 'state_class': , + 'unit_of_measurement': , + }), + 'fitbit-api-user-id-1_body/weight', + ) +# --- +# name: test_sensors[monitored_resources12-sensor.body_fat-body/fat-18] + tuple( + '18.0', + ReadOnlyDict({ + 'attribution': 'Data provided by Fitbit.com', + 'friendly_name': 'Body Fat', + 'icon': 'mdi:human', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'fitbit-api-user-id-1_body/fat', + ) +# --- +# name: test_sensors[monitored_resources13-sensor.bmi-body/bmi-23.7] + tuple( + '23.7', + ReadOnlyDict({ + 'attribution': 'Data provided by Fitbit.com', + 'friendly_name': 'BMI', + 'icon': 'mdi:human', + 'state_class': , + 'unit_of_measurement': 'BMI', + }), + 'fitbit-api-user-id-1_body/bmi', + ) +# --- +# name: test_sensors[monitored_resources14-sensor.awakenings_count-sleep/awakeningsCount-7] + tuple( + '7', + ReadOnlyDict({ + 'attribution': 'Data provided by Fitbit.com', + 'friendly_name': 'Awakenings Count', + 'icon': 'mdi:sleep', + 'unit_of_measurement': 'times awaken', + }), + 'fitbit-api-user-id-1_sleep/awakeningsCount', + ) +# --- +# name: test_sensors[monitored_resources15-sensor.sleep_efficiency-sleep/efficiency-80] + tuple( + '80', + ReadOnlyDict({ + 'attribution': 'Data provided by Fitbit.com', + 'friendly_name': 'Sleep Efficiency', + 'icon': 'mdi:sleep', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'fitbit-api-user-id-1_sleep/efficiency', + ) +# --- +# name: test_sensors[monitored_resources16-sensor.minutes_after_wakeup-sleep/minutesAfterWakeup-17] + tuple( + '17', + ReadOnlyDict({ + 'attribution': 'Data provided by Fitbit.com', + 'device_class': 'duration', + 'friendly_name': 'Minutes After Wakeup', + 'icon': 'mdi:sleep', + 'unit_of_measurement': , + }), + 'fitbit-api-user-id-1_sleep/minutesAfterWakeup', + ) +# --- +# name: test_sensors[monitored_resources17-sensor.sleep_minutes_asleep-sleep/minutesAsleep-360] + tuple( + '360', + ReadOnlyDict({ + 'attribution': 'Data provided by Fitbit.com', + 'device_class': 'duration', + 'friendly_name': 'Sleep Minutes Asleep', + 'icon': 'mdi:sleep', + 'unit_of_measurement': , + }), + 'fitbit-api-user-id-1_sleep/minutesAsleep', + ) +# --- +# name: test_sensors[monitored_resources18-sensor.sleep_minutes_awake-sleep/minutesAwake-35] + tuple( + '35', + ReadOnlyDict({ + 'attribution': 'Data provided by Fitbit.com', + 'device_class': 'duration', + 'friendly_name': 'Sleep Minutes Awake', + 'icon': 'mdi:sleep', + 'unit_of_measurement': , + }), + 'fitbit-api-user-id-1_sleep/minutesAwake', + ) +# --- +# name: test_sensors[monitored_resources19-sensor.sleep_minutes_to_fall_asleep-sleep/minutesToFallAsleep-35] + tuple( + '35', + ReadOnlyDict({ + 'attribution': 'Data provided by Fitbit.com', + 'device_class': 'duration', + 'friendly_name': 'Sleep Minutes to Fall Asleep', + 'icon': 'mdi:sleep', + 'unit_of_measurement': , + }), + 'fitbit-api-user-id-1_sleep/minutesToFallAsleep', + ) +# --- +# name: test_sensors[monitored_resources2-sensor.distance-activities/distance-12.7] + tuple( + '12.70', + ReadOnlyDict({ + 'attribution': 'Data provided by Fitbit.com', + 'device_class': 'distance', + 'friendly_name': 'Distance', + 'icon': 'mdi:map-marker', + 'unit_of_measurement': , + }), + 'fitbit-api-user-id-1_activities/distance', + ) +# --- +# name: test_sensors[monitored_resources20-sensor.sleep_start_time-sleep/startTime-2020-01-27T00:17:30.000] + tuple( + '2020-01-27T00:17:30.000', + ReadOnlyDict({ + 'attribution': 'Data provided by Fitbit.com', + 'friendly_name': 'Sleep Start Time', + 'icon': 'mdi:clock', + }), + 'fitbit-api-user-id-1_sleep/startTime', + ) +# --- +# name: test_sensors[monitored_resources21-sensor.sleep_time_in_bed-sleep/timeInBed-462] + tuple( + '462', + ReadOnlyDict({ + 'attribution': 'Data provided by Fitbit.com', + 'device_class': 'duration', + 'friendly_name': 'Sleep Time in Bed', + 'icon': 'mdi:hotel', + 'unit_of_measurement': , + }), + 'fitbit-api-user-id-1_sleep/timeInBed', + ) +# --- +# name: test_sensors[monitored_resources3-sensor.elevation-activities/elevation-7600.24] + tuple( + '7600.24', + ReadOnlyDict({ + 'attribution': 'Data provided by Fitbit.com', + 'device_class': 'distance', + 'friendly_name': 'Elevation', + 'icon': 'mdi:walk', + 'unit_of_measurement': , + }), + 'fitbit-api-user-id-1_activities/elevation', + ) +# --- +# name: test_sensors[monitored_resources4-sensor.floors-activities/floors-8] + tuple( + '8', + ReadOnlyDict({ + 'attribution': 'Data provided by Fitbit.com', + 'friendly_name': 'Floors', + 'icon': 'mdi:walk', + 'unit_of_measurement': 'floors', + }), + 'fitbit-api-user-id-1_activities/floors', + ) +# --- +# name: test_sensors[monitored_resources5-sensor.resting_heart_rate-activities/heart-api_value5] + tuple( + '76', + ReadOnlyDict({ + 'attribution': 'Data provided by Fitbit.com', + 'friendly_name': 'Resting Heart Rate', + 'icon': 'mdi:heart-pulse', + 'unit_of_measurement': 'bpm', + }), + 'fitbit-api-user-id-1_activities/heart', + ) +# --- +# name: test_sensors[monitored_resources6-sensor.minutes_fairly_active-activities/minutesFairlyActive-35] + tuple( + '35', + ReadOnlyDict({ + 'attribution': 'Data provided by Fitbit.com', + 'device_class': 'duration', + 'friendly_name': 'Minutes Fairly Active', + 'icon': 'mdi:walk', + 'unit_of_measurement': , + }), + 'fitbit-api-user-id-1_activities/minutesFairlyActive', + ) +# --- +# name: test_sensors[monitored_resources7-sensor.minutes_lightly_active-activities/minutesLightlyActive-95] + tuple( + '95', + ReadOnlyDict({ + 'attribution': 'Data provided by Fitbit.com', + 'device_class': 'duration', + 'friendly_name': 'Minutes Lightly Active', + 'icon': 'mdi:walk', + 'unit_of_measurement': , + }), + 'fitbit-api-user-id-1_activities/minutesLightlyActive', + ) +# --- +# name: test_sensors[monitored_resources8-sensor.minutes_sedentary-activities/minutesSedentary-18] + tuple( + '18', + ReadOnlyDict({ + 'attribution': 'Data provided by Fitbit.com', + 'device_class': 'duration', + 'friendly_name': 'Minutes Sedentary', + 'icon': 'mdi:seat-recline-normal', + 'unit_of_measurement': , + }), + 'fitbit-api-user-id-1_activities/minutesSedentary', + ) +# --- +# name: test_sensors[monitored_resources9-sensor.minutes_very_active-activities/minutesVeryActive-20] + tuple( + '20', + ReadOnlyDict({ + 'attribution': 'Data provided by Fitbit.com', + 'device_class': 'duration', + 'friendly_name': 'Minutes Very Active', + 'icon': 'mdi:run', + 'unit_of_measurement': , + }), + 'fitbit-api-user-id-1_activities/minutesVeryActive', + ) +# --- diff --git a/tests/components/fitbit/test_sensor.py b/tests/components/fitbit/test_sensor.py new file mode 100644 index 00000000000..636afeacf16 --- /dev/null +++ b/tests/components/fitbit/test_sensor.py @@ -0,0 +1,332 @@ +"""Tests for the fitbit sensor platform.""" + + +from collections.abc import Awaitable, Callable +from typing import Any + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .conftest import PROFILE_USER_ID, timeseries_response + +DEVICE_RESPONSE_CHARGE_2 = { + "battery": "Medium", + "batteryLevel": 60, + "deviceVersion": "Charge 2", + "id": "816713257", + "lastSyncTime": "2019-11-07T12:00:58.000", + "mac": "16ADD56D54GD", + "type": "TRACKER", +} +DEVICE_RESPONSE_ARIA_AIR = { + "battery": "High", + "batteryLevel": 95, + "deviceVersion": "Aria Air", + "id": "016713257", + "lastSyncTime": "2019-11-07T12:00:58.000", + "mac": "06ADD56D54GD", + "type": "SCALE", +} + + +@pytest.mark.parametrize( + ( + "monitored_resources", + "entity_id", + "api_resource", + "api_value", + ), + [ + ( + ["activities/activityCalories"], + "sensor.activity_calories", + "activities/activityCalories", + "135", + ), + ( + ["activities/calories"], + "sensor.calories", + "activities/calories", + "139", + ), + ( + ["activities/distance"], + "sensor.distance", + "activities/distance", + "12.7", + ), + ( + ["activities/elevation"], + "sensor.elevation", + "activities/elevation", + "7600.24", + ), + ( + ["activities/floors"], + "sensor.floors", + "activities/floors", + "8", + ), + ( + ["activities/heart"], + "sensor.resting_heart_rate", + "activities/heart", + {"restingHeartRate": 76}, + ), + ( + ["activities/minutesFairlyActive"], + "sensor.minutes_fairly_active", + "activities/minutesFairlyActive", + 35, + ), + ( + ["activities/minutesLightlyActive"], + "sensor.minutes_lightly_active", + "activities/minutesLightlyActive", + 95, + ), + ( + ["activities/minutesSedentary"], + "sensor.minutes_sedentary", + "activities/minutesSedentary", + 18, + ), + ( + ["activities/minutesVeryActive"], + "sensor.minutes_very_active", + "activities/minutesVeryActive", + 20, + ), + ( + ["activities/steps"], + "sensor.steps", + "activities/steps", + "5600", + ), + ( + ["body/weight"], + "sensor.weight", + "body/weight", + "175", + ), + ( + ["body/fat"], + "sensor.body_fat", + "body/fat", + "18", + ), + ( + ["body/bmi"], + "sensor.bmi", + "body/bmi", + "23.7", + ), + ( + ["sleep/awakeningsCount"], + "sensor.awakenings_count", + "sleep/awakeningsCount", + "7", + ), + ( + ["sleep/efficiency"], + "sensor.sleep_efficiency", + "sleep/efficiency", + "80", + ), + ( + ["sleep/minutesAfterWakeup"], + "sensor.minutes_after_wakeup", + "sleep/minutesAfterWakeup", + "17", + ), + ( + ["sleep/minutesAsleep"], + "sensor.sleep_minutes_asleep", + "sleep/minutesAsleep", + "360", + ), + ( + ["sleep/minutesAwake"], + "sensor.sleep_minutes_awake", + "sleep/minutesAwake", + "35", + ), + ( + ["sleep/minutesToFallAsleep"], + "sensor.sleep_minutes_to_fall_asleep", + "sleep/minutesToFallAsleep", + "35", + ), + ( + ["sleep/startTime"], + "sensor.sleep_start_time", + "sleep/startTime", + "2020-01-27T00:17:30.000", + ), + ( + ["sleep/timeInBed"], + "sensor.sleep_time_in_bed", + "sleep/timeInBed", + "462", + ), + ], +) +async def test_sensors( + hass: HomeAssistant, + sensor_platform_setup: Callable[[], Awaitable[bool]], + register_timeseries: Callable[[str, dict[str, Any]], None], + entity_registry: er.EntityRegistry, + entity_id: str, + api_resource: str, + api_value: str, + snapshot: SnapshotAssertion, +) -> None: + """Test sensors.""" + + register_timeseries( + api_resource, timeseries_response(api_resource.replace("/", "-"), api_value) + ) + await sensor_platform_setup() + + state = hass.states.get(entity_id) + assert state + entry = entity_registry.async_get(entity_id) + assert entry + assert (state.state, state.attributes, entry.unique_id) == snapshot + + +@pytest.mark.parametrize( + ("devices_response", "monitored_resources"), + [([DEVICE_RESPONSE_CHARGE_2, DEVICE_RESPONSE_ARIA_AIR], ["devices/battery"])], +) +async def test_device_battery_level( + hass: HomeAssistant, + sensor_platform_setup: Callable[[], Awaitable[bool]], + entity_registry: er.EntityRegistry, +) -> None: + """Test battery level sensor for devices.""" + + await sensor_platform_setup() + + state = hass.states.get("sensor.charge_2_battery") + assert state + assert state.state == "Medium" + assert state.attributes == { + "attribution": "Data provided by Fitbit.com", + "friendly_name": "Charge 2 Battery", + "icon": "mdi:battery-50", + "model": "Charge 2", + "type": "tracker", + } + + entry = entity_registry.async_get("sensor.charge_2_battery") + assert entry + assert entry.unique_id == f"{PROFILE_USER_ID}_devices/battery_816713257" + + state = hass.states.get("sensor.aria_air_battery") + assert state + assert state.state == "High" + assert state.attributes == { + "attribution": "Data provided by Fitbit.com", + "friendly_name": "Aria Air Battery", + "icon": "mdi:battery", + "model": "Aria Air", + "type": "scale", + } + + entity_registry = er.async_get(hass) + entry = entity_registry.async_get("sensor.aria_air_battery") + assert entry + assert entry.unique_id == f"{PROFILE_USER_ID}_devices/battery_016713257" + + +@pytest.mark.parametrize( + ( + "monitored_resources", + "profile_locale", + "configured_unit_system", + "expected_unit", + ), + [ + # Defaults to home assistant unit system unless UK + (["body/weight"], "en_US", "default", "kg"), + (["body/weight"], "en_GB", "default", "st"), + (["body/weight"], "es_ES", "default", "kg"), + # Use the configured unit system from yaml + (["body/weight"], "en_US", "en_US", "lb"), + (["body/weight"], "en_GB", "en_US", "lb"), + (["body/weight"], "es_ES", "en_US", "lb"), + (["body/weight"], "en_US", "en_GB", "st"), + (["body/weight"], "en_GB", "en_GB", "st"), + (["body/weight"], "es_ES", "en_GB", "st"), + (["body/weight"], "en_US", "metric", "kg"), + (["body/weight"], "en_GB", "metric", "kg"), + (["body/weight"], "es_ES", "metric", "kg"), + ], +) +async def test_profile_local( + hass: HomeAssistant, + sensor_platform_setup: Callable[[], Awaitable[bool]], + register_timeseries: Callable[[str, dict[str, Any]], None], + expected_unit: str, +) -> None: + """Test the fitbit profile locale impact on unit of measure.""" + + register_timeseries("body/weight", timeseries_response("body-weight", "175")) + await sensor_platform_setup() + + state = hass.states.get("sensor.weight") + assert state + assert state.attributes.get("unit_of_measurement") == expected_unit + + +@pytest.mark.parametrize( + ("sensor_platform_config", "api_response", "expected_state"), + [ + ( + {"clock_format": "12H", "monitored_resources": ["sleep/startTime"]}, + "17:05", + "5:05 PM", + ), + ( + {"clock_format": "12H", "monitored_resources": ["sleep/startTime"]}, + "5:05", + "5:05 AM", + ), + ( + {"clock_format": "12H", "monitored_resources": ["sleep/startTime"]}, + "00:05", + "12:05 AM", + ), + ( + {"clock_format": "24H", "monitored_resources": ["sleep/startTime"]}, + "17:05", + "17:05", + ), + ( + {"clock_format": "12H", "monitored_resources": ["sleep/startTime"]}, + "", + "-", + ), + ], +) +async def test_sleep_time_clock_format( + hass: HomeAssistant, + sensor_platform_setup: Callable[[], Awaitable[bool]], + register_timeseries: Callable[[str, dict[str, Any]], None], + api_response: str, + expected_state: str, +) -> None: + """Test the clock format configuration.""" + + register_timeseries( + "sleep/startTime", timeseries_response("sleep-startTime", api_response) + ) + await sensor_platform_setup() + + state = hass.states.get("sensor.sleep_start_time") + assert state + assert state.state == expected_state diff --git a/tests/components/flipr/test_init.py b/tests/components/flipr/test_init.py index e9685bd6e0a..c1c5c0086e7 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 fc02cdb4123..080e47acc3e 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/conftest.py b/tests/components/freebox/conftest.py index 69b250412bd..63bc1d76d1a 100644 --- a/tests/components/freebox/conftest.py +++ b/tests/components/freebox/conftest.py @@ -1,5 +1,5 @@ """Test helpers for Freebox.""" -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, PropertyMock, patch import pytest @@ -10,6 +10,7 @@ from .const import ( DATA_CALL_GET_CALLS_LOG, DATA_CONNECTION_GET_STATUS, DATA_HOME_GET_NODES, + DATA_HOME_GET_VALUES, DATA_LAN_GET_HOSTS_LIST, DATA_STORAGE_GET_DISKS, DATA_STORAGE_GET_RAIDS, @@ -27,6 +28,16 @@ def mock_path(): yield +@pytest.fixture(autouse=True) +def enable_all_entities(): + """Make sure all entities are enabled.""" + with patch( + "homeassistant.helpers.entity.Entity.entity_registry_enabled_default", + PropertyMock(return_value=True), + ): + yield + + @pytest.fixture def mock_device_registry_devices(hass: HomeAssistant, device_registry): """Create device registry devices so the device tracker entities are enabled.""" @@ -56,18 +67,21 @@ def mock_router(mock_device_registry_devices): instance = service_mock.return_value instance.open = AsyncMock() instance.system.get_config = AsyncMock(return_value=DATA_SYSTEM_GET_CONFIG) + # device_tracker + instance.lan.get_hosts_list = AsyncMock(return_value=DATA_LAN_GET_HOSTS_LIST) # sensor instance.call.get_calls_log = AsyncMock(return_value=DATA_CALL_GET_CALLS_LOG) instance.storage.get_disks = AsyncMock(return_value=DATA_STORAGE_GET_DISKS) instance.storage.get_raids = AsyncMock(return_value=DATA_STORAGE_GET_RAIDS) - # home devices - instance.home.get_home_nodes = AsyncMock(return_value=DATA_HOME_GET_NODES) instance.connection.get_status = AsyncMock( return_value=DATA_CONNECTION_GET_STATUS ) # switch instance.wifi.get_global_config = AsyncMock(return_value=WIFI_GET_GLOBAL_CONFIG) - # device_tracker - instance.lan.get_hosts_list = AsyncMock(return_value=DATA_LAN_GET_HOSTS_LIST) + # home devices + instance.home.get_home_nodes = AsyncMock(return_value=DATA_HOME_GET_NODES) + instance.home.get_home_endpoint_value = AsyncMock( + return_value=DATA_HOME_GET_VALUES + ) instance.close = AsyncMock() yield service_mock diff --git a/tests/components/freebox/const.py b/tests/components/freebox/const.py index 0b58348a5df..788310bdbc0 100644 --- a/tests/components/freebox/const.py +++ b/tests/components/freebox/const.py @@ -513,7 +513,22 @@ DATA_LAN_GET_HOSTS_LIST = [ }, ] +# Home +# PIR node id 26, endpoint id 6 +DATA_HOME_GET_VALUES = { + "category": "", + "ep_type": "signal", + "id": 6, + "label": "Détection", + "name": "trigger", + "ui": {"access": "w", "display": "toggle"}, + "value": False, + "value_type": "bool", + "visibility": "normal", +} +# Home +# ALL DATA_HOME_GET_NODES = [ { "adapter": 2, @@ -2110,6 +2125,22 @@ DATA_HOME_GET_NODES = [ "value_type": "bool", "visibility": "normal", }, + { + "category": "", + "ep_type": "signal", + "id": 7, + "label": "Couvercle", + "name": "cover", + "refresh": 2000, + "ui": { + "access": "r", + "display": "warning", + "icon_url": "/resources/images/home/pictos/warning.png", + }, + "value": False, + "value_type": "bool", + "visibility": "normal", + }, { "category": "", "ep_type": "signal", @@ -2211,7 +2242,7 @@ DATA_HOME_GET_NODES = [ "ep_type": "signal", "id": 7, "label": "Couvercle", - "name": "1cover", + "name": "cover", "param_type": "void", "value_type": "bool", "visibility": "normal", @@ -2302,6 +2333,33 @@ DATA_HOME_GET_NODES = [ "value_type": "bool", "visibility": "normal", }, + { + "category": "", + "ep_type": "signal", + "id": 6, + "label": "Détection", + "name": "trigger", + "ui": {"access": "w", "display": "toggle"}, + "value": False, + "value_type": "bool", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "signal", + "id": 7, + "label": "Couvercle", + "name": "cover", + "refresh": 2000, + "ui": { + "access": "r", + "display": "warning", + "icon_url": "/resources/images/home/pictos/warning.png", + }, + "value": False, + "value_type": "bool", + "visibility": "normal", + }, { "category": "", "ep_type": "signal", diff --git a/tests/components/freebox/test_binary_sensor.py b/tests/components/freebox/test_binary_sensor.py index ec504a514ad..b37d6a3c72c 100644 --- a/tests/components/freebox/test_binary_sensor.py +++ b/tests/components/freebox/test_binary_sensor.py @@ -1,29 +1,28 @@ """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, + BinarySensorDeviceClass, +) +from homeassistant.components.freebox import SCAN_INTERVAL +from homeassistant.const import ATTR_DEVICE_CLASS 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_HOME_GET_VALUES, 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,10 +34,55 @@ 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 ( hass.states.get("binary_sensor.freebox_server_r2_raid_array_0_degraded").state == "on" ) + + +async def test_home( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, router: Mock +) -> None: + """Test home binary sensors.""" + await setup_platform(hass, BINARY_SENSOR_DOMAIN) + + # Device class + assert ( + hass.states.get("binary_sensor.detecteur").attributes[ATTR_DEVICE_CLASS] + == BinarySensorDeviceClass.MOTION + ) + assert ( + hass.states.get("binary_sensor.ouverture_porte").attributes[ATTR_DEVICE_CLASS] + == BinarySensorDeviceClass.DOOR + ) + assert ( + hass.states.get("binary_sensor.ouverture_porte_couvercle").attributes[ + ATTR_DEVICE_CLASS + ] + == BinarySensorDeviceClass.SAFETY + ) + + # Initial state + assert hass.states.get("binary_sensor.detecteur").state == "on" + assert hass.states.get("binary_sensor.detecteur_couvercle").state == "off" + assert hass.states.get("binary_sensor.ouverture_porte").state == "unknown" + assert hass.states.get("binary_sensor.ouverture_porte_couvercle").state == "off" + + # Now simulate a changed status + data_home_get_values_changed = deepcopy(DATA_HOME_GET_VALUES) + data_home_get_values_changed["value"] = True + router().home.get_home_endpoint_value.return_value = data_home_get_values_changed + + # Simulate an update + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("binary_sensor.detecteur").state == "off" + assert hass.states.get("binary_sensor.detecteur_couvercle").state == "on" + assert hass.states.get("binary_sensor.ouverture_porte").state == "off" + assert hass.states.get("binary_sensor.ouverture_porte_couvercle").state == "on" diff --git a/tests/components/freebox/test_button.py b/tests/components/freebox/test_button.py index de15e90f54f..5f72b5968f1 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 tests.common import MockConfigEntry +from .common import setup_platform -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 d8ea7107f23..9d6f95b2559 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 .const import MOCK_HOST, MOCK_PORT 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 index 41daa79fe4e..0abdc55b92c 100644 --- a/tests/components/freebox/test_sensor.py +++ b/tests/components/freebox/test_sensor.py @@ -9,11 +9,57 @@ from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant from .common import setup_platform -from .const import DATA_HOME_GET_NODES, DATA_STORAGE_GET_DISKS +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: @@ -58,8 +104,8 @@ async def test_battery( # 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 + data_home_get_nodes_changed[3]["show_endpoints"][4]["value"] = 50 + data_home_get_nodes_changed[4]["show_endpoints"][5]["value"] = 75 router().home.get_home_nodes.return_value = data_home_get_nodes_changed # Simulate an update freezer.tick(SCAN_INTERVAL) diff --git a/tests/components/fritzbox/test_init.py b/tests/components/fritzbox/test_init.py index dd5a8127185..b07b8225c3e 100644 --- a/tests/components/fritzbox/test_init.py +++ b/tests/components/fritzbox/test_init.py @@ -21,12 +21,14 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.setup import async_setup_component from . import FritzDeviceSwitchMock, setup_config_entry from .const import CONF_FAKE_AIN, CONF_FAKE_NAME, MOCK_CONFIG from tests.common import MockConfigEntry +from tests.typing import WebSocketGenerator async def test_setup(hass: HomeAssistant, fritz: Mock) -> None: @@ -250,6 +252,68 @@ async def test_unload_remove(hass: HomeAssistant, fritz: Mock) -> None: assert state is None +async def test_remove_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, + fritz: Mock, +) -> None: + """Test removing of a device.""" + assert await async_setup_component(hass, "config", {}) + assert await setup_config_entry( + hass, + MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], + f"{FB_DOMAIN}.{CONF_FAKE_NAME}", + FritzDeviceSwitchMock(), + fritz, + ) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries() + assert len(entries) == 1 + + entry = entries[0] + assert entry.supports_remove_device + + entity = entity_registry.async_get("switch.fake_name") + good_device = device_registry.async_get(entity.device_id) + + orphan_device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(FB_DOMAIN, "0000 000000")}, + ) + + # try to delete good_device + ws_client = await hass_ws_client(hass) + await ws_client.send_json( + { + "id": 5, + "type": "config/device_registry/remove_config_entry", + "config_entry_id": entry.entry_id, + "device_id": good_device.id, + } + ) + response = await ws_client.receive_json() + assert not response["success"] + assert response["error"]["code"] == "unknown_error" + await hass.async_block_till_done() + + # try to delete orphan_device + ws_client = await hass_ws_client(hass) + await ws_client.send_json( + { + "id": 5, + "type": "config/device_registry/remove_config_entry", + "config_entry_id": entry.entry_id, + "device_id": orphan_device.id, + } + ) + response = await ws_client.receive_json() + assert response["success"] + await hass.async_block_till_done() + + async def test_raise_config_entry_not_ready_when_offline(hass: HomeAssistant) -> None: """Config entry state is SETUP_RETRY when fritzbox is offline.""" entry = MockConfigEntry( diff --git a/tests/components/glances/test_config_flow.py b/tests/components/glances/test_config_flow.py index 187e319fe08..87ec80da057 100644 --- a/tests/components/glances/test_config_flow.py +++ b/tests/components/glances/test_config_flow.py @@ -1,7 +1,10 @@ """Tests for Glances config flow.""" from unittest.mock import MagicMock -from glances_api.exceptions import GlancesApiConnectionError +from glances_api.exceptions import ( + GlancesApiAuthorizationError, + GlancesApiConnectionError, +) import pytest from homeassistant import config_entries @@ -9,7 +12,7 @@ from homeassistant.components import glances from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from . import MOCK_USER_INPUT +from . import HA_SENSOR_DATA, MOCK_USER_INPUT from tests.common import MockConfigEntry, patch @@ -35,14 +38,23 @@ 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 -async def test_form_cannot_connect(hass: HomeAssistant, mock_api: MagicMock) -> None: - """Test to return error if we cannot connect.""" +@pytest.mark.parametrize( + ("error", "message"), + [ + (GlancesApiAuthorizationError, "invalid_auth"), + (GlancesApiConnectionError, "cannot_connect"), + ], +) +async def test_form_fails( + hass: HomeAssistant, error: Exception, message: str, mock_api: MagicMock +) -> None: + """Test flow fails when api exception is raised.""" - mock_api.return_value.get_ha_sensor_data.side_effect = GlancesApiConnectionError + mock_api.return_value.get_ha_sensor_data.side_effect = [error, HA_SENSOR_DATA] result = await hass.config_entries.flow.async_init( glances.DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -51,7 +63,13 @@ async def test_form_cannot_connect(hass: HomeAssistant, mock_api: MagicMock) -> ) assert result["type"] == FlowResultType.FORM - assert result["errors"] == {"base": "cannot_connect"} + assert result["errors"] == {"base": message} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_USER_INPUT + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY async def test_form_already_configured(hass: HomeAssistant) -> None: @@ -67,3 +85,81 @@ async def test_form_already_configured(hass: HomeAssistant) -> None: ) assert result["type"] == FlowResultType.ABORT assert result["reason"] == "already_configured" + + +async def test_reauth_success(hass: HomeAssistant) -> None: + """Test we can reauth.""" + entry = MockConfigEntry(domain=glances.DOMAIN, data=MOCK_USER_INPUT) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + glances.DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": entry.entry_id, + }, + data=MOCK_USER_INPUT, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["description_placeholders"] == {"username": "username"} + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "password": "new-password", + }, + ) + + assert result2["type"] == FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" + + +@pytest.mark.parametrize( + ("error", "message"), + [ + (GlancesApiAuthorizationError, "invalid_auth"), + (GlancesApiConnectionError, "cannot_connect"), + ], +) +async def test_reauth_fails( + hass: HomeAssistant, error: Exception, message: str, mock_api: MagicMock +) -> None: + """Test we can reauth.""" + entry = MockConfigEntry(domain=glances.DOMAIN, data=MOCK_USER_INPUT) + entry.add_to_hass(hass) + + mock_api.return_value.get_ha_sensor_data.side_effect = [error, HA_SENSOR_DATA] + result = await hass.config_entries.flow.async_init( + glances.DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": entry.entry_id, + }, + data=MOCK_USER_INPUT, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["description_placeholders"] == {"username": "username"} + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "password": "new-password", + }, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": message} + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "password": "new-password", + }, + ) + + assert result3["type"] == FlowResultType.ABORT + assert result3["reason"] == "reauth_successful" diff --git a/tests/components/glances/test_init.py b/tests/components/glances/test_init.py index 546f57ac3d9..61cbc610060 100644 --- a/tests/components/glances/test_init.py +++ b/tests/components/glances/test_init.py @@ -1,7 +1,11 @@ """Tests for Glances integration.""" from unittest.mock import MagicMock -from glances_api.exceptions import GlancesApiConnectionError +from glances_api.exceptions import ( + GlancesApiAuthorizationError, + GlancesApiConnectionError, +) +import pytest from homeassistant.components.glances.const import DOMAIN from homeassistant.config_entries import ConfigEntryState @@ -23,15 +27,27 @@ async def test_successful_config_entry(hass: HomeAssistant) -> None: assert entry.state == ConfigEntryState.LOADED -async def test_conn_error(hass: HomeAssistant, mock_api: MagicMock) -> None: - """Test Glances failed due to connection error.""" +@pytest.mark.parametrize( + ("error", "entry_state"), + [ + (GlancesApiAuthorizationError, ConfigEntryState.SETUP_ERROR), + (GlancesApiConnectionError, ConfigEntryState.SETUP_RETRY), + ], +) +async def test_setup_error( + hass: HomeAssistant, + error: Exception, + entry_state: ConfigEntryState, + mock_api: MagicMock, +) -> None: + """Test Glances failed due to api error.""" entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_INPUT) entry.add_to_hass(hass) - mock_api.return_value.get_ha_sensor_data.side_effect = GlancesApiConnectionError + mock_api.return_value.get_ha_sensor_data.side_effect = error await hass.config_entries.async_setup(entry.entry_id) - assert entry.state is ConfigEntryState.SETUP_RETRY + assert entry.state is entry_state async def test_unload_entry(hass: HomeAssistant) -> None: diff --git a/tests/components/gogogate2/test_config_flow.py b/tests/components/gogogate2/test_config_flow.py index 32d0f197bb5..6de04125783 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 17f300f58cb..233635510e0 100644 --- a/tests/components/google/test_init.py +++ b/tests/components/google/test_init.py @@ -20,7 +20,7 @@ from homeassistant.config_entries import ConfigEntryState 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/snapshots/test_diagnostics.ambr b/tests/components/google_assistant/snapshots/test_diagnostics.ambr index 8d425ae0648..dffcddf5de5 100644 --- a/tests/components/google_assistant/snapshots/test_diagnostics.ambr +++ b/tests/components/google_assistant/snapshots/test_diagnostics.ambr @@ -87,6 +87,7 @@ 'binary_sensor', 'climate', 'cover', + 'event', 'fan', 'group', 'humidifier', diff --git a/tests/components/google_assistant/test_helpers.py b/tests/components/google_assistant/test_helpers.py index 17df677110b..57915968933 100644 --- a/tests/components/google_assistant/test_helpers.py +++ b/tests/components/google_assistant/test_helpers.py @@ -306,7 +306,7 @@ async def test_agent_user_id_connect() -> None: @pytest.mark.parametrize("agents", [{}, {"1"}, {"1", "2"}]) async def test_report_state_all(agents) -> None: - """Test a disconnect message.""" + """Test sync of all states.""" config = MockConfig(agent_user_ids=agents) data = {} with patch.object(config, "async_report_state") as mock: @@ -314,6 +314,28 @@ async def test_report_state_all(agents) -> None: assert sorted(mock.mock_calls) == sorted(call(data, agent) for agent in agents) +@pytest.mark.parametrize("agents", [{}, {"1"}, {"1", "2"}]) +async def test_sync_entities(agents) -> None: + """Test sync of all entities.""" + config = MockConfig(agent_user_ids=agents) + with patch.object( + config, "async_sync_entities", return_value=HTTPStatus.NO_CONTENT + ) as mock: + await config.async_sync_entities_all() + assert sorted(mock.mock_calls) == sorted(call(agent) for agent in agents) + + +@pytest.mark.parametrize("agents", [{}, {"1"}, {"1", "2"}]) +async def test_sync_notifications(agents) -> None: + """Test sync of notifications.""" + config = MockConfig(agent_user_ids=agents) + with patch.object( + config, "async_sync_notification", return_value=HTTPStatus.NO_CONTENT + ) as mock: + await config.async_sync_notification_all("1234", {}) + assert not agents or bool(mock.mock_calls) and agents + + @pytest.mark.parametrize( ("agents", "result"), [({}, 204), ({"1": 200}, 200), ({"1": 200, "2": 300}, 300)], @@ -447,32 +469,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), + } - mock_traits.reset_mock() + hass.states.async_set("light.new", "on") + google_entities = helpers.async_get_entities(hass, config) - # Supported feature is same, so we do not calculate again - mock_traits.side_effect = ValueError + 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), + } - assert entity(2).is_supported() is True - assert len(mock_traits.mock_calls) == 0 + hass.states.async_set("light.new", "on", {"supported_features": 1}) + google_entities = helpers.async_get_entities(hass, config) + + 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_http.py b/tests/components/google_assistant/test_http.py index 44dc40f5a47..62d2722c445 100644 --- a/tests/components/google_assistant/test_http.py +++ b/tests/components/google_assistant/test_http.py @@ -3,6 +3,7 @@ from datetime import UTC, datetime, timedelta from http import HTTPStatus from typing import Any from unittest.mock import ANY, patch +from uuid import uuid4 import pytest @@ -195,6 +196,38 @@ async def test_report_state( ) +async def test_report_event( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_storage: dict[str, Any], +) -> None: + """Test the report event function.""" + agent_user_id = "user" + config = GoogleConfig(hass, DUMMY_CONFIG) + await config.async_initialize() + + await config.async_connect_agent_user(agent_user_id) + message = {"devices": {}} + + with patch.object(config, "async_call_homegraph_api"): + # Wait for google_assistant.helpers.async_initialize.sync_google to be called + await hass.async_block_till_done() + + event_id = uuid4().hex + with patch.object(config, "async_call_homegraph_api") as mock_call: + # Wait for google_assistant.helpers.async_initialize.sync_google to be called + await config.async_report_state(message, agent_user_id, event_id=event_id) + mock_call.assert_called_once_with( + REPORT_STATE_BASE_URL, + { + "requestId": ANY, + "agentUserId": agent_user_id, + "payload": message, + "eventId": event_id, + }, + ) + + async def test_google_config_local_fulfillment( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, diff --git a/tests/components/google_assistant/test_report_state.py b/tests/components/google_assistant/test_report_state.py index 3fe2a749fca..4ec61b75171 100644 --- a/tests/components/google_assistant/test_report_state.py +++ b/tests/components/google_assistant/test_report_state.py @@ -1,5 +1,7 @@ """Test Google report state.""" -from datetime import timedelta +from datetime import datetime, timedelta +from http import HTTPStatus +from time import mktime from unittest.mock import AsyncMock, patch import pytest @@ -9,7 +11,7 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow -from . import BASIC_CONFIG +from . import BASIC_CONFIG, MockConfig from tests.common import async_fire_time_changed @@ -21,6 +23,9 @@ async def test_report_state( assert await async_setup_component(hass, "switch", {}) hass.states.async_set("light.ceiling", "off") hass.states.async_set("switch.ac", "on") + hass.states.async_set( + "event.doorbell", "unknown", attributes={"device_class": "doorbell"} + ) with patch.object( BASIC_CONFIG, "async_report_state_all", AsyncMock() @@ -37,6 +42,7 @@ async def test_report_state( "states": { "light.ceiling": {"on": False, "online": True}, "switch.ac": {"on": True, "online": True}, + "event.doorbell": {"online": True}, } } } @@ -69,7 +75,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 +110,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") @@ -128,3 +134,145 @@ async def test_report_state( await hass.async_block_till_done() assert len(mock_report.mock_calls) == 0 + + +@pytest.mark.freeze_time("2023-08-01 00:00:00") +async def test_report_notifications( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test report state works.""" + config = MockConfig(agent_user_ids={"1"}) + + assert await async_setup_component(hass, "event", {}) + hass.states.async_set( + "event.doorbell", "unknown", attributes={"device_class": "doorbell"} + ) + + with patch.object( + config, "async_report_state_all", AsyncMock() + ) as mock_report, patch.object(report_state, "INITIAL_REPORT_DELAY", 0): + report_state.async_enable_report_state(hass, config) + + async_fire_time_changed( + hass, datetime.fromisoformat("2023-08-01T00:01:00+00:00") + ) + await hass.async_block_till_done() + + # Test that enabling report state does a report on event entities + assert len(mock_report.mock_calls) == 1 + assert mock_report.mock_calls[0][1][0] == { + "devices": { + "states": { + "event.doorbell": {"online": True}, + }, + } + } + + with patch.object( + config, "async_report_state", return_value=HTTPStatus(200) + ) as mock_report_state: + event_time = datetime.fromisoformat("2023-08-01T00:02:57+00:00") + epoc_event_time = int(mktime(event_time.timetuple())) + hass.states.async_set( + "event.doorbell", + "2023-08-01T00:02:57+00:00", + attributes={"device_class": "doorbell"}, + ) + async_fire_time_changed( + hass, datetime.fromisoformat("2023-08-01T00:03:00+00:00") + ) + await hass.async_block_till_done() + + assert len(mock_report_state.mock_calls) == 1 + notifications_payload = mock_report_state.mock_calls[0][1][0]["devices"][ + "notifications" + ]["event.doorbell"] + assert notifications_payload == { + "ObjectDetection": { + "objects": {"unclassified": 1}, + "priority": 0, + "detectionTimestamp": epoc_event_time * 1000, + } + } + assert "Sending event notification for entity event.doorbell" in caplog.text + assert "Unable to send notification with result code" not in caplog.text + + hass.states.async_set( + "event.doorbell", "unknown", attributes={"device_class": "doorbell"} + ) + async_fire_time_changed( + hass, datetime.fromisoformat("2023-08-01T01:01:00+00:00") + ) + await hass.async_block_till_done() + + # Test the notification request failed + caplog.clear() + with patch.object( + config, "async_report_state", return_value=HTTPStatus(500) + ) as mock_report_state: + event_time = datetime.fromisoformat("2023-08-01T01:02:57+00:00") + epoc_event_time = int(mktime(event_time.timetuple())) + hass.states.async_set( + "event.doorbell", + "2023-08-01T01:02:57+00:00", + attributes={"device_class": "doorbell"}, + ) + async_fire_time_changed( + hass, datetime.fromisoformat("2023-08-01T01:03:00+00:00") + ) + await hass.async_block_till_done() + assert len(mock_report_state.mock_calls) == 2 + for call in mock_report_state.mock_calls: + if "notifications" in call[1][0]["devices"]: + notifications = call[1][0]["devices"]["notifications"] + elif "states" in call[1][0]["devices"]: + states = call[1][0]["devices"]["states"] + assert notifications["event.doorbell"] == { + "ObjectDetection": { + "objects": {"unclassified": 1}, + "priority": 0, + "detectionTimestamp": epoc_event_time * 1000, + } + } + assert states["event.doorbell"] == {"online": True} + assert "Sending event notification for entity event.doorbell" in caplog.text + assert ( + "Unable to send notification with result code: 500, check log for more info" + in caplog.text + ) + + # Test disconnecting agent user + caplog.clear() + with patch.object( + config, "async_report_state", return_value=HTTPStatus.NOT_FOUND + ) as mock_report_state, patch.object(config, "async_disconnect_agent_user"): + event_time = datetime.fromisoformat("2023-08-01T01:03:57+00:00") + epoc_event_time = int(mktime(event_time.timetuple())) + hass.states.async_set( + "event.doorbell", + "2023-08-01T01:03:57+00:00", + attributes={"device_class": "doorbell"}, + ) + async_fire_time_changed( + hass, datetime.fromisoformat("2023-08-01T01:04:00+00:00") + ) + await hass.async_block_till_done() + assert len(mock_report_state.mock_calls) == 2 + for call in mock_report_state.mock_calls: + if "notifications" in call[1][0]["devices"]: + notifications = call[1][0]["devices"]["notifications"] + elif "states" in call[1][0]["devices"]: + states = call[1][0]["devices"]["states"] + assert notifications["event.doorbell"] == { + "ObjectDetection": { + "objects": {"unclassified": 1}, + "priority": 0, + "detectionTimestamp": epoc_event_time * 1000, + } + } + assert states["event.doorbell"] == {"online": True} + assert "Sending event notification for entity event.doorbell" in caplog.text + assert ( + "Unable to send notification with result code: 404, check log for more info" + in caplog.text + ) diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index fcbf16c21c7..db4257bb621 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -11,6 +11,7 @@ from homeassistant.components import ( camera, climate, cover, + event, fan, group, humidifier, @@ -220,6 +221,42 @@ async def test_onoff_input_boolean(hass: HomeAssistant) -> None: assert off_calls[0].data == {ATTR_ENTITY_ID: "input_boolean.bla"} +@pytest.mark.freeze_time("2023-08-01T00:02:57+00:00") +async def test_doorbell_event(hass: HomeAssistant) -> None: + """Test doorbell event trait support for input_boolean domain.""" + assert trait.ObjectDetection.supported(event.DOMAIN, 0, "doorbell", None) + + state = State( + "event.bla", + "2023-08-01T00:02:57+00:00", + attributes={"device_class": "doorbell"}, + ) + trt_od = trait.ObjectDetection(hass, state, BASIC_CONFIG) + + assert not trt_od.sync_attributes() + assert trt_od.sync_options() == {"notificationSupportedByAgent": True} + assert not trt_od.query_attributes() + time_stamp = datetime.fromisoformat(state.state) + assert trt_od.query_notifications() == { + "ObjectDetection": { + "objects": { + "unclassified": 1, + }, + "priority": 0, + "detectionTimestamp": int(time_stamp.timestamp() * 1000), + } + } + + # Test that stale notifications (older than 30 s) are dropped + state = State( + "event.bla", + "2023-08-01T00:02:22+00:00", + attributes={"device_class": "doorbell"}, + ) + trt_od = trait.ObjectDetection(hass, state, BASIC_CONFIG) + assert trt_od.query_notifications() is None + + async def test_onoff_switch(hass: HomeAssistant) -> None: """Test OnOff trait support for switch domain.""" assert helpers.get_google_type(switch.DOMAIN, None) is not None diff --git a/tests/components/guardian/test_config_flow.py b/tests/components/guardian/test_config_flow.py index cb28ea22a37..3d0be516dea 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/hardkernel/test_init.py b/tests/components/hardkernel/test_init.py index f202777f530..877a44a2ca2 100644 --- a/tests/components/hardkernel/test_init.py +++ b/tests/components/hardkernel/test_init.py @@ -26,7 +26,8 @@ async def test_setup_entry(hass: HomeAssistant) -> None: ) as mock_get_os_info: assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert len(mock_get_os_info.mock_calls) == 1 + + assert len(mock_get_os_info.mock_calls) == 1 async def test_setup_entry_wrong_board(hass: HomeAssistant) -> None: @@ -41,13 +42,17 @@ async def test_setup_entry_wrong_board(hass: HomeAssistant) -> None: title="Hardkernel", ) config_entry.add_to_hass(hass) + assert len(hass.config_entries.async_entries()) == 1 + with patch( "homeassistant.components.hardkernel.get_os_info", return_value={"board": "generic-x86-64"}, ) as mock_get_os_info: assert not await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert len(mock_get_os_info.mock_calls) == 1 + + assert len(mock_get_os_info.mock_calls) == 1 + assert len(hass.config_entries.async_entries()) == 0 async def test_setup_entry_wait_hassio(hass: HomeAssistant) -> None: @@ -68,5 +73,6 @@ async def test_setup_entry_wait_hassio(hass: HomeAssistant) -> None: ) as mock_get_os_info: assert not await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert len(mock_get_os_info.mock_calls) == 1 - assert config_entry.state == ConfigEntryState.SETUP_RETRY + + assert len(mock_get_os_info.mock_calls) == 1 + assert config_entry.state == ConfigEntryState.SETUP_RETRY diff --git a/tests/components/hassio/test_handler.py b/tests/components/hassio/test_handler.py index 5a89ea8335a..06c726360d9 100644 --- a/tests/components/hassio/test_handler.py +++ b/tests/components/hassio/test_handler.py @@ -320,8 +320,8 @@ async def test_api_ingress_panels( ], ) async def test_api_headers( + aiohttp_raw_server, # 'aiohttp_raw_server' must be before 'hass'! hass, - aiohttp_raw_server, socket_enabled, api_call: str, method: Literal["GET", "POST"], @@ -364,6 +364,48 @@ async def test_api_headers( assert received_request.headers[hdrs.CONTENT_TYPE] == "application/octet-stream" +async def test_api_get_green_settings( + hass: HomeAssistant, hassio_stubs, aioclient_mock: AiohttpClientMocker +) -> None: + """Test setup with API ping.""" + aioclient_mock.get( + "http://127.0.0.1/os/boards/green", + json={ + "result": "ok", + "data": { + "activity_led": True, + "power_led": True, + "system_health_led": True, + }, + }, + ) + + assert await handler.async_get_green_settings(hass) == { + "activity_led": True, + "power_led": True, + "system_health_led": True, + } + assert aioclient_mock.call_count == 1 + + +async def test_api_set_green_settings( + hass: HomeAssistant, hassio_stubs, aioclient_mock: AiohttpClientMocker +) -> None: + """Test setup with API ping.""" + aioclient_mock.post( + "http://127.0.0.1/os/boards/green", + json={"result": "ok", "data": {}}, + ) + + assert ( + await handler.async_set_green_settings( + hass, {"activity_led": True, "power_led": True, "system_health_led": True} + ) + == {} + ) + assert aioclient_mock.call_count == 1 + + async def test_api_get_yellow_settings( hass: HomeAssistant, hassio_stubs, aioclient_mock: AiohttpClientMocker ) -> None: diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index 48f52ee7c24..adb462b02e3 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -548,7 +548,7 @@ async def test_service_calls( assert aioclient_mock.call_count == 30 assert aioclient_mock.mock_calls[-1][2] == { - "name": "2021-11-13 11:48:00", + "name": "2021-11-13 03:48:00", "homeassistant": True, "addons": ["test"], "folders": ["ssl"], @@ -605,6 +605,24 @@ async def test_service_calls( await hass.async_block_till_done() assert aioclient_mock.call_count == 34 + assert aioclient_mock.mock_calls[-1][2] == { + "name": "2021-11-13 03:48:00", + "location": None, + } + + # check backup with different timezone + await hass.config.async_update(time_zone="Europe/London") + + await hass.services.async_call( + "hassio", + "backup_full", + { + "location": "/backup", + }, + ) + await hass.async_block_till_done() + + assert aioclient_mock.call_count == 36 assert aioclient_mock.mock_calls[-1][2] == { "name": "2021-11-13 11:48:00", "location": None, diff --git a/tests/components/homeassistant/test_init.py b/tests/components/homeassistant/test_init.py index 652fc4a1fdd..4c5643ae3ca 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_green/test_config_flow.py b/tests/components/homeassistant_green/test_config_flow.py index 2eb7389af55..84af22509f9 100644 --- a/tests/components/homeassistant_green/test_config_flow.py +++ b/tests/components/homeassistant_green/test_config_flow.py @@ -1,6 +1,8 @@ """Test the Home Assistant Green config flow.""" from unittest.mock import patch +import pytest + from homeassistant.components.homeassistant_green.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -8,6 +10,29 @@ from homeassistant.data_entry_flow import FlowResultType from tests.common import MockConfigEntry, MockModule, mock_integration +@pytest.fixture(name="get_green_settings") +def mock_get_green_settings(): + """Mock getting green settings.""" + with patch( + "homeassistant.components.homeassistant_green.config_flow.async_get_green_settings", + return_value={ + "activity_led": True, + "power_led": True, + "system_health_led": True, + }, + ) as get_green_settings: + yield get_green_settings + + +@pytest.fixture(name="set_green_settings") +def mock_set_green_settings(): + """Mock setting green settings.""" + with patch( + "homeassistant.components.homeassistant_green.config_flow.async_set_green_settings", + ) as set_green_settings: + yield set_green_settings + + async def test_config_flow(hass: HomeAssistant) -> None: """Test the config flow.""" mock_integration(hass, MockModule("hassio")) @@ -56,3 +81,142 @@ async def test_config_flow_single_entry(hass: HomeAssistant) -> None: assert result["type"] == FlowResultType.ABORT assert result["reason"] == "single_instance_allowed" mock_setup_entry.assert_not_called() + + +async def test_option_flow_non_hassio( + hass: HomeAssistant, +) -> None: + """Test installing the multi pan addon on a Core installation, without hassio.""" + mock_integration(hass, MockModule("hassio")) + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={}, + title="Home Assistant Green", + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.homeassistant_green.config_flow.is_hassio", + return_value=False, + ): + result = await hass.config_entries.options.async_init(config_entry.entry_id) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "not_hassio" + + +async def test_option_flow_led_settings( + hass: HomeAssistant, + get_green_settings, + set_green_settings, +) -> None: + """Test updating LED settings.""" + mock_integration(hass, MockModule("hassio")) + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={}, + title="Home Assistant Green", + ) + config_entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "hardware_settings" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {"activity_led": False, "power_led": False, "system_health_led": False}, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + set_green_settings.assert_called_once_with( + hass, {"activity_led": False, "power_led": False, "system_health_led": False} + ) + + +async def test_option_flow_led_settings_unchanged( + hass: HomeAssistant, + get_green_settings, + set_green_settings, +) -> None: + """Test updating LED settings.""" + mock_integration(hass, MockModule("hassio")) + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={}, + title="Home Assistant Green", + ) + config_entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "hardware_settings" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {"activity_led": True, "power_led": True, "system_health_led": True}, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + set_green_settings.assert_not_called() + + +async def test_option_flow_led_settings_fail_1(hass: HomeAssistant) -> None: + """Test updating LED settings.""" + mock_integration(hass, MockModule("hassio")) + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={}, + title="Home Assistant Green", + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.homeassistant_green.config_flow.async_get_green_settings", + side_effect=TimeoutError, + ): + result = await hass.config_entries.options.async_init(config_entry.entry_id) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "read_hw_settings_error" + + +async def test_option_flow_led_settings_fail_2( + hass: HomeAssistant, get_green_settings +) -> None: + """Test updating LED settings.""" + mock_integration(hass, MockModule("hassio")) + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={}, + title="Home Assistant Green", + ) + config_entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "hardware_settings" + + with patch( + "homeassistant.components.homeassistant_green.config_flow.async_set_green_settings", + side_effect=TimeoutError, + ): + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {"activity_led": False, "power_led": False, "system_health_led": False}, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "write_hw_settings_error" diff --git a/tests/components/homeassistant_green/test_hardware.py b/tests/components/homeassistant_green/test_hardware.py index 8aacf09978d..0221bf3a577 100644 --- a/tests/components/homeassistant_green/test_hardware.py +++ b/tests/components/homeassistant_green/test_hardware.py @@ -54,7 +54,7 @@ async def test_hardware_info( "config_entries": [config_entry.entry_id], "dongle": None, "name": "Home Assistant Green", - "url": None, + "url": "https://green.home-assistant.io/documentation/", } ] } diff --git a/tests/components/homeassistant_green/test_init.py b/tests/components/homeassistant_green/test_init.py index f48aea3fdfb..0df7d918039 100644 --- a/tests/components/homeassistant_green/test_init.py +++ b/tests/components/homeassistant_green/test_init.py @@ -44,13 +44,17 @@ async def test_setup_entry_wrong_board(hass: HomeAssistant) -> None: title="Home Assistant Green", ) config_entry.add_to_hass(hass) + assert len(hass.config_entries.async_entries()) == 1 + with patch( "homeassistant.components.homeassistant_green.get_os_info", return_value={"board": "generic-x86-64"}, ) as mock_get_os_info: assert not await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert len(mock_get_os_info.mock_calls) == 1 + + assert len(mock_get_os_info.mock_calls) == 1 + assert len(hass.config_entries.async_entries()) == 0 async def test_setup_entry_wait_hassio(hass: HomeAssistant) -> None: @@ -71,5 +75,6 @@ async def test_setup_entry_wait_hassio(hass: HomeAssistant) -> None: ) as mock_get_os_info: assert not await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert len(mock_get_os_info.mock_calls) == 1 - assert config_entry.state == ConfigEntryState.SETUP_RETRY + + assert len(mock_get_os_info.mock_calls) == 1 + assert config_entry.state == ConfigEntryState.SETUP_RETRY diff --git a/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py b/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py index 17cd288050c..fbc77cdee9e 100644 --- a/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py +++ b/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py @@ -85,7 +85,7 @@ class FakeOptionsFlow(silabs_multiprotocol_addon.OptionsFlowHandler): def _zha_name(self) -> str: """Return the ZHA name.""" - return "Test Multi-PAN" + return "Test Multiprotocol" def _hardware_name(self) -> str: """Return the name of the hardware.""" @@ -353,7 +353,7 @@ async def test_option_flow_install_multi_pan_addon_zha( }, "radio_type": "ezsp", } - assert zha_config_entry.title == "Test Multi-PAN" + assert zha_config_entry.title == "Test Multiprotocol" result = await hass.config_entries.options.async_configure(result["flow_id"]) assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE @@ -663,7 +663,7 @@ async def test_option_flow_addon_installed_same_device_uninstall( }, domain=ZHA_DOMAIN, options={}, - title="Test Multi-PAN", + title="Test Multiprotocol", ) zha_config_entry.add_to_hass(hass) @@ -928,7 +928,7 @@ async def test_option_flow_flasher_install_failure( }, domain=ZHA_DOMAIN, options={}, - title="Test Multi-PAN", + title="Test Multiprotocol", ) zha_config_entry.add_to_hass(hass) @@ -1071,7 +1071,7 @@ async def test_option_flow_uninstall_migration_initiate_failure( }, domain=ZHA_DOMAIN, options={}, - title="Test Multi-PAN", + title="Test Multiprotocol", ) zha_config_entry.add_to_hass(hass) @@ -1132,7 +1132,7 @@ async def test_option_flow_uninstall_migration_finish_failure( }, domain=ZHA_DOMAIN, options={}, - title="Test Multi-PAN", + title="Test Multiprotocol", ) zha_config_entry.add_to_hass(hass) diff --git a/tests/components/homeassistant_sky_connect/test_init.py b/tests/components/homeassistant_sky_connect/test_init.py index 3afc8c24774..e00603dc8f7 100644 --- a/tests/components/homeassistant_sky_connect/test_init.py +++ b/tests/components/homeassistant_sky_connect/test_init.py @@ -207,7 +207,7 @@ async def test_setup_zha_multipan( "radio_type": "ezsp", } assert config_entry.options == {} - assert config_entry.title == "SkyConnect Multi-PAN" + assert config_entry.title == "SkyConnect Multiprotocol" async def test_setup_zha_multipan_other_device( diff --git a/tests/components/homeassistant_yellow/test_init.py b/tests/components/homeassistant_yellow/test_init.py index c0e4165ba20..addc519c865 100644 --- a/tests/components/homeassistant_yellow/test_init.py +++ b/tests/components/homeassistant_yellow/test_init.py @@ -37,7 +37,8 @@ async def test_setup_entry( ): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert len(mock_get_os_info.mock_calls) == 1 + + assert len(mock_get_os_info.mock_calls) == 1 # Finish setting up ZHA if num_entries > 0: @@ -151,7 +152,7 @@ async def test_setup_zha_multipan( "radio_type": "ezsp", } assert config_entry.options == {} - assert config_entry.title == "Yellow Multi-PAN" + assert config_entry.title == "Yellow Multiprotocol" async def test_setup_zha_multipan_other_device( @@ -216,13 +217,17 @@ async def test_setup_entry_wrong_board(hass: HomeAssistant) -> None: title="Home Assistant Yellow", ) config_entry.add_to_hass(hass) + assert len(hass.config_entries.async_entries()) == 1 + with patch( "homeassistant.components.homeassistant_yellow.get_os_info", return_value={"board": "generic-x86-64"}, ) as mock_get_os_info: assert not await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert len(mock_get_os_info.mock_calls) == 1 + + assert len(mock_get_os_info.mock_calls) == 1 + assert len(hass.config_entries.async_entries()) == 0 async def test_setup_entry_wait_hassio(hass: HomeAssistant) -> None: @@ -243,8 +248,9 @@ async def test_setup_entry_wait_hassio(hass: HomeAssistant) -> None: ) as mock_get_os_info: assert not await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert len(mock_get_os_info.mock_calls) == 1 - assert config_entry.state == ConfigEntryState.SETUP_RETRY + + assert len(mock_get_os_info.mock_calls) == 1 + assert config_entry.state == ConfigEntryState.SETUP_RETRY async def test_setup_entry_addon_info_fails( @@ -269,8 +275,9 @@ async def test_setup_entry_addon_info_fails( "homeassistant.components.onboarding.async_is_onboarded", return_value=False ): assert not await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - assert config_entry.state == ConfigEntryState.SETUP_RETRY + + await hass.async_block_till_done() + assert config_entry.state == ConfigEntryState.SETUP_RETRY async def test_setup_entry_addon_not_running( @@ -295,5 +302,6 @@ async def test_setup_entry_addon_not_running( ): assert not await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert config_entry.state == ConfigEntryState.SETUP_RETRY - start_addon.assert_called_once() + + assert config_entry.state == ConfigEntryState.SETUP_RETRY + start_addon.assert_called_once() diff --git a/tests/components/homekit/test_get_accessories.py b/tests/components/homekit/test_get_accessories.py index b57dd2da10f..960647a22e6 100644 --- a/tests/components/homekit/test_get_accessories.py +++ b/tests/components/homekit/test_get_accessories.py @@ -17,7 +17,10 @@ from homeassistant.components.homekit.const import ( TYPE_SWITCH, TYPE_VALVE, ) -from homeassistant.components.media_player import MediaPlayerEntityFeature +from homeassistant.components.media_player import ( + MediaPlayerDeviceClass, + MediaPlayerEntityFeature, +) from homeassistant.components.sensor import SensorDeviceClass from homeassistant.components.vacuum import VacuumEntityFeature from homeassistant.const import ( @@ -202,7 +205,14 @@ def test_type_covers(type_name, entity_id, state, attrs) -> None: "TelevisionMediaPlayer", "media_player.tv", "on", - {ATTR_DEVICE_CLASS: "tv"}, + {ATTR_DEVICE_CLASS: MediaPlayerDeviceClass.TV}, + {}, + ), + ( + "ReceiverMediaPlayer", + "media_player.receiver", + "on", + {ATTR_DEVICE_CLASS: MediaPlayerDeviceClass.RECEIVER}, {}, ), ], diff --git a/tests/components/homekit/test_homekit.py b/tests/components/homekit/test_homekit.py index 02807ba6557..00281b491c4 100644 --- a/tests/components/homekit/test_homekit.py +++ b/tests/components/homekit/test_homekit.py @@ -765,6 +765,7 @@ async def test_homekit_start( assert device formatted_mac = dr.format_mac(homekit.driver.state.mac) assert (dr.CONNECTION_NETWORK_MAC, formatted_mac) in device.connections + assert device.model == "HomeBridge" assert len(device_registry.devices) == 1 assert homekit.driver.state.config_version == 1 @@ -2010,6 +2011,16 @@ async def test_homekit_start_in_accessory_mode( assert hk_driver_start.called assert homekit.status == STATUS_RUNNING + device = device_registry.async_get_device( + identifiers={(DOMAIN, entry.entry_id, BRIDGE_SERIAL_NUMBER)} + ) + assert device + formatted_mac = dr.format_mac(homekit.driver.state.mac) + assert (dr.CONNECTION_NETWORK_MAC, formatted_mac) in device.connections + assert device.model == "Light" + + assert len(device_registry.devices) == 1 + async def test_homekit_start_in_accessory_mode_unsupported_entity( hass: HomeAssistant, diff --git a/tests/components/homekit/test_type_cameras.py b/tests/components/homekit/test_type_cameras.py index 9fcd36d06f3..fdb092467f3 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/test_type_covers.py b/tests/components/homekit/test_type_covers.py index 9da576b6a0e..b8841289611 100644 --- a/tests/components/homekit/test_type_covers.py +++ b/tests/components/homekit/test_type_covers.py @@ -74,17 +74,19 @@ async def test_garage_door_open_close(hass: HomeAssistant, hk_driver, events) -> assert acc.char_obstruction_detected.value is True hass.states.async_set( - entity_id, STATE_UNAVAILABLE, {ATTR_OBSTRUCTION_DETECTED: False} + entity_id, STATE_UNAVAILABLE, {ATTR_OBSTRUCTION_DETECTED: True} ) await hass.async_block_till_done() assert acc.char_current_state.value == HK_DOOR_OPEN assert acc.char_target_state.value == HK_DOOR_OPEN - assert acc.char_obstruction_detected.value is False + assert acc.char_obstruction_detected.value is True + assert acc.available is False hass.states.async_set(entity_id, STATE_UNKNOWN) await hass.async_block_till_done() assert acc.char_current_state.value == HK_DOOR_OPEN assert acc.char_target_state.value == HK_DOOR_OPEN + assert acc.available is True # Set from HomeKit call_close_cover = async_mock_service(hass, DOMAIN, "close_cover") diff --git a/tests/components/homekit/test_type_locks.py b/tests/components/homekit/test_type_locks.py index 32f1561644e..dc614ee54c4 100644 --- a/tests/components/homekit/test_type_locks.py +++ b/tests/components/homekit/test_type_locks.py @@ -13,6 +13,7 @@ from homeassistant.const import ( ATTR_CODE, ATTR_ENTITY_ID, STATE_LOCKED, + STATE_UNAVAILABLE, STATE_UNKNOWN, STATE_UNLOCKED, ) @@ -68,10 +69,32 @@ async def test_lock_unlock(hass: HomeAssistant, hk_driver, events) -> None: assert acc.char_current_state.value == 3 assert acc.char_target_state.value == 0 - hass.states.async_remove(entity_id) + # Unavailable should keep last state + # but set the accessory to not available + hass.states.async_set(entity_id, STATE_UNAVAILABLE) await hass.async_block_till_done() assert acc.char_current_state.value == 3 assert acc.char_target_state.value == 0 + assert acc.available is False + + hass.states.async_set(entity_id, STATE_UNLOCKED) + await hass.async_block_till_done() + assert acc.char_current_state.value == 0 + assert acc.char_target_state.value == 0 + assert acc.available is True + + # Unavailable should keep last state + # but set the accessory to not available + hass.states.async_set(entity_id, STATE_UNAVAILABLE) + await hass.async_block_till_done() + assert acc.char_current_state.value == 0 + assert acc.char_target_state.value == 0 + assert acc.available is False + + hass.states.async_remove(entity_id) + await hass.async_block_till_done() + assert acc.char_current_state.value == 0 + assert acc.char_target_state.value == 0 # Set from HomeKit call_lock = async_mock_service(hass, DOMAIN, "lock") diff --git a/tests/components/homekit/test_type_media_players.py b/tests/components/homekit/test_type_media_players.py index f68adc24077..3842303ec84 100644 --- a/tests/components/homekit/test_type_media_players.py +++ b/tests/components/homekit/test_type_media_players.py @@ -1,6 +1,7 @@ """Test different accessory types: Media Players.""" import pytest +from homeassistant.components.homekit.accessories import HomeDriver from homeassistant.components.homekit.const import ( ATTR_KEY_NAME, ATTR_VALUE, @@ -15,6 +16,7 @@ from homeassistant.components.homekit.const import ( ) from homeassistant.components.homekit.type_media_players import ( MediaPlayer, + ReceiverMediaPlayer, TelevisionMediaPlayer, ) from homeassistant.components.media_player import ( @@ -629,3 +631,29 @@ async def test_media_player_television_unsafe_chars( assert events[-1].data[ATTR_VALUE] is None assert acc.char_input_source.value == 4 + + +async def test_media_player_receiver( + hass: HomeAssistant, hk_driver: HomeDriver, caplog: pytest.LogCaptureFixture +) -> None: + """Test if television accessory with unsafe characters.""" + entity_id = "media_player.receiver" + sources = ["MUSIC", "HDMI 3/ARC", "SCREEN MIRRORING", "HDMI 2/MHL", "HDMI", "MUSIC"] + hass.states.async_set( + entity_id, + None, + { + ATTR_DEVICE_CLASS: MediaPlayerDeviceClass.TV, + ATTR_SUPPORTED_FEATURES: 3469, + ATTR_MEDIA_VOLUME_MUTED: False, + ATTR_INPUT_SOURCE: "HDMI 2/MHL", + ATTR_INPUT_SOURCE_LIST: sources, + }, + ) + await hass.async_block_till_done() + acc = ReceiverMediaPlayer(hass, hk_driver, "MediaPlayer", entity_id, 2, None) + await acc.run() + await hass.async_block_till_done() + + assert acc.aid == 2 + assert acc.category == 34 # Receiver diff --git a/tests/components/homekit_controller/snapshots/test_init.ambr b/tests/components/homekit_controller/snapshots/test_init.ambr new file mode 100644 index 00000000000..4c408f2887e --- /dev/null +++ b/tests/components/homekit_controller/snapshots/test_init.ambr @@ -0,0 +1,12712 @@ +# serializer version: 1 +# name: test_snapshots[airversa_ap2] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '0.1', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Sleekpoint Innovations', + 'model': 'AP2', + 'name': 'Airversa AP2 1808', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '0.8.16', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.airversa_ap2_1808_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Airversa AP2 1808 Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Airversa AP2 1808 Identify', + }), + 'entity_id': 'button.airversa_ap2_1808_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.airversa_ap2_1808_provision_preferred_thread_credentials', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Airversa AP2 1808 Provision Preferred Thread Credentials', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_112_119', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Airversa AP2 1808 Provision Preferred Thread Credentials', + }), + 'entity_id': 'button.airversa_ap2_1808_provision_preferred_thread_credentials', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.airversa_ap2_1808_air_quality', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Airversa AP2 1808 Air Quality', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_2576_2579', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'aqi', + 'friendly_name': 'Airversa AP2 1808 Air Quality', + 'state_class': , + }), + 'entity_id': 'sensor.airversa_ap2_1808_air_quality', + 'state': '1', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.airversa_ap2_1808_filter_lifetime', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Airversa AP2 1808 Filter lifetime', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_32896_32900', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Airversa AP2 1808 Filter lifetime', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.airversa_ap2_1808_filter_lifetime', + 'state': '100.0', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.airversa_ap2_1808_pm2_5_density', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Airversa AP2 1808 PM2.5 Density', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_2576_2580', + 'unit_of_measurement': 'µg/m³', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'pm25', + 'friendly_name': 'Airversa AP2 1808 PM2.5 Density', + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'entity_id': 'sensor.airversa_ap2_1808_pm2_5_density', + 'state': '3.0', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'border_router_capable', + 'full', + 'minimal', + 'none', + 'router_eligible', + 'sleepy', + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.airversa_ap2_1808_thread_capabilities', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Airversa AP2 1808 Thread Capabilities', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': 'thread_node_capabilities', + 'unique_id': '00:00:00:00:00:00_1_112_115', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'enum', + 'friendly_name': 'Airversa AP2 1808 Thread Capabilities', + 'options': list([ + 'border_router_capable', + 'full', + 'minimal', + 'none', + 'router_eligible', + 'sleepy', + ]), + }), + 'entity_id': 'sensor.airversa_ap2_1808_thread_capabilities', + 'state': 'router_eligible', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'border_router', + 'child', + 'detached', + 'disabled', + 'joining', + 'leader', + 'router', + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.airversa_ap2_1808_thread_status', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Airversa AP2 1808 Thread Status', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': 'thread_status', + 'unique_id': '00:00:00:00:00:00_1_112_117', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'enum', + 'friendly_name': 'Airversa AP2 1808 Thread Status', + 'options': list([ + 'border_router', + 'child', + 'detached', + 'disabled', + 'joining', + 'leader', + 'router', + ]), + }), + 'entity_id': 'sensor.airversa_ap2_1808_thread_status', + 'state': 'router', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.airversa_ap2_1808_lock_physical_controls', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:lock-open', + 'original_name': 'Airversa AP2 1808 Lock Physical Controls', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_32832_32839', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Airversa AP2 1808 Lock Physical Controls', + 'icon': 'mdi:lock-open', + }), + 'entity_id': 'switch.airversa_ap2_1808_lock_physical_controls', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.airversa_ap2_1808_mute', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:volume-mute', + 'original_name': 'Airversa AP2 1808 Mute', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_32832_32843', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Airversa AP2 1808 Mute', + 'icon': 'mdi:volume-mute', + }), + 'entity_id': 'switch.airversa_ap2_1808_mute', + 'state': 'on', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.airversa_ap2_1808_sleep_mode', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:power-sleep', + 'original_name': 'Airversa AP2 1808 Sleep Mode', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_32832_32842', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Airversa AP2 1808 Sleep Mode', + 'icon': 'mdi:power-sleep', + }), + 'entity_id': 'switch.airversa_ap2_1808_sleep_mode', + 'state': 'off', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[anker_eufycam] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '2.0.0', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Anker', + 'model': 'T8010', + 'name': 'eufy HomeBase2-0AAA', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '2.1.6', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.eufy_homebase2_0aaa_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'eufy HomeBase2-0AAA Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'eufy HomeBase2-0AAA Identify', + }), + 'entity_id': 'button.eufy_homebase2_0aaa_identify', + 'state': 'unknown', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.0.0', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:4', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Anker', + 'model': 'T8113', + 'name': 'eufyCam2-0000', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.6.7', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.eufycam2_0000_motion_sensor', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'eufyCam2-0000 Motion Sensor', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4_160', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'eufyCam2-0000 Motion Sensor', + }), + 'entity_id': 'binary_sensor.eufycam2_0000_motion_sensor', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.eufycam2_0000_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'eufyCam2-0000 Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'eufyCam2-0000 Identify', + }), + 'entity_id': 'button.eufycam2_0000_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'camera', + 'entity_category': None, + 'entity_id': 'camera.eufycam2_0000', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'eufyCam2-0000', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'eufyCam2-0000', + 'supported_features': , + }), + 'entity_id': 'camera.eufycam2_0000', + 'state': 'idle', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.eufycam2_0000_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'eufyCam2-0000 Battery', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4_101', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'eufyCam2-0000 Battery', + 'icon': 'mdi:battery-20', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.eufycam2_0000_battery', + 'state': '17', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.eufycam2_0000_mute', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:volume-mute', + 'original_name': 'eufyCam2-0000 Mute', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4_80_83', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'eufyCam2-0000 Mute', + 'icon': 'mdi:volume-mute', + }), + 'entity_id': 'switch.eufycam2_0000_mute', + 'state': 'off', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.0.0', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:2', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Anker', + 'model': 'T8113', + 'name': 'eufyCam2-000A', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.6.7', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.eufycam2_000a_motion_sensor', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'eufyCam2-000A Motion Sensor', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_2_160', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'eufyCam2-000A Motion Sensor', + }), + 'entity_id': 'binary_sensor.eufycam2_000a_motion_sensor', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.eufycam2_000a_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'eufyCam2-000A Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_2_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'eufyCam2-000A Identify', + }), + 'entity_id': 'button.eufycam2_000a_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'camera', + 'entity_category': None, + 'entity_id': 'camera.eufycam2_000a', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'eufyCam2-000A', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'eufyCam2-000A', + 'supported_features': , + }), + 'entity_id': 'camera.eufycam2_000a', + 'state': 'idle', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.eufycam2_000a_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'eufyCam2-000A Battery', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_2_101', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'eufyCam2-000A Battery', + 'icon': 'mdi:battery-40', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.eufycam2_000a_battery', + 'state': '38', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.eufycam2_000a_mute', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:volume-mute', + 'original_name': 'eufyCam2-000A Mute', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_2_80_83', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'eufyCam2-000A Mute', + 'icon': 'mdi:volume-mute', + }), + 'entity_id': 'switch.eufycam2_000a_mute', + 'state': 'off', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.0.0', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:3', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Anker', + 'model': 'T8113', + 'name': 'eufyCam2-000A', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.6.7', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.eufycam2_000a_motion_sensor_2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'eufyCam2-000A Motion Sensor', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_3_160', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'eufyCam2-000A Motion Sensor', + }), + 'entity_id': 'binary_sensor.eufycam2_000a_motion_sensor_2', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.eufycam2_000a_identify_2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'eufyCam2-000A Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_3_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'eufyCam2-000A Identify', + }), + 'entity_id': 'button.eufycam2_000a_identify_2', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'camera', + 'entity_category': None, + 'entity_id': 'camera.eufycam2_000a_2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'eufyCam2-000A', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_3', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'eufyCam2-000A', + 'supported_features': , + }), + 'entity_id': 'camera.eufycam2_000a_2', + 'state': 'idle', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.eufycam2_000a_battery_2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'eufyCam2-000A Battery', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_3_101', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'eufyCam2-000A Battery', + 'icon': 'mdi:battery-alert', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.eufycam2_000a_battery_2', + 'state': '100', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.eufycam2_000a_mute_2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:volume-mute', + 'original_name': 'eufyCam2-000A Mute', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_3_80_83', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'eufyCam2-000A Mute', + 'icon': 'mdi:volume-mute', + }), + 'entity_id': 'switch.eufycam2_000a_mute_2', + 'state': 'off', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[aqara_e1] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.0', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Aqara', + 'model': 'HE1-G01', + 'name': 'Aqara-Hub-E1-00A0', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '3.3.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'alarm_control_panel', + 'entity_category': None, + 'entity_id': 'alarm_control_panel.aqara_hub_e1_00a0_security_system', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:security', + 'original_name': 'Aqara-Hub-E1-00A0 Security System', + 'platform': 'homekit_controller', + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_16', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'changed_by': None, + 'code_arm_required': True, + 'code_format': None, + 'friendly_name': 'Aqara-Hub-E1-00A0 Security System', + 'icon': 'mdi:security', + 'supported_features': , + }), + 'entity_id': 'alarm_control_panel.aqara_hub_e1_00a0_security_system', + 'state': 'disarmed', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.aqara_hub_e1_00a0_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Aqara-Hub-E1-00A0 Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_65537', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Aqara-Hub-E1-00A0 Identify', + }), + 'entity_id': 'button.aqara_hub_e1_00a0_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'max': 100, + 'min': 0.0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.aqara_hub_e1_00a0_volume', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:volume-high', + 'original_name': 'Aqara-Hub-E1-00A0 Volume', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_17_1114116', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Aqara-Hub-E1-00A0 Volume', + 'icon': 'mdi:volume-high', + 'max': 100, + 'min': 0.0, + 'mode': , + 'step': 1, + }), + 'entity_id': 'number.aqara_hub_e1_00a0_volume', + 'state': '40', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.aqara_hub_e1_00a0_pairing_mode', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:lock-open', + 'original_name': 'Aqara-Hub-E1-00A0 Pairing Mode', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_17_1114117', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Aqara-Hub-E1-00A0 Pairing Mode', + 'icon': 'mdi:lock-open', + }), + 'entity_id': 'switch.aqara_hub_e1_00a0_pairing_mode', + 'state': 'off', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.0', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:33', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Aqara', + 'model': 'AS006', + 'name': 'Contact Sensor', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.contact_sensor', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Contact Sensor', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_33_4', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'opening', + 'friendly_name': 'Contact Sensor', + }), + 'entity_id': 'binary_sensor.contact_sensor', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.contact_sensor_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Contact Sensor Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_33_1_65537', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Contact Sensor Identify', + }), + 'entity_id': 'button.contact_sensor_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.contact_sensor_battery_sensor', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Contact Sensor Battery Sensor', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_33_5', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'Contact Sensor Battery Sensor', + 'icon': 'mdi:battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.contact_sensor_battery_sensor', + 'state': '100', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[aqara_gateway] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Aqara', + 'model': 'ZHWA11LM', + 'name': 'Aqara Hub-1563', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.4.7', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'alarm_control_panel', + 'entity_category': None, + 'entity_id': 'alarm_control_panel.aqara_hub_1563_security_system', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:security', + 'original_name': 'Aqara Hub-1563 Security System', + 'platform': 'homekit_controller', + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_66304', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'changed_by': None, + 'code_arm_required': True, + 'code_format': None, + 'friendly_name': 'Aqara Hub-1563 Security System', + 'icon': 'mdi:security', + 'supported_features': , + }), + 'entity_id': 'alarm_control_panel.aqara_hub_1563_security_system', + 'state': 'disarmed', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.aqara_hub_1563_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Aqara Hub-1563 Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_7', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Aqara Hub-1563 Identify', + }), + 'entity_id': 'button.aqara_hub_1563_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.aqara_hub_1563_lightbulb_1563', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Aqara Hub-1563 Lightbulb-1563', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_65792', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Aqara Hub-1563 Lightbulb-1563', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'entity_id': 'light.aqara_hub_1563_lightbulb_1563', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'max': 100, + 'min': 0.0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.aqara_hub_1563_volume', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:volume-high', + 'original_name': 'Aqara Hub-1563 Volume', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_65536_65541', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Aqara Hub-1563 Volume', + 'icon': 'mdi:volume-high', + 'max': 100, + 'min': 0.0, + 'mode': , + 'step': 1, + }), + 'entity_id': 'number.aqara_hub_1563_volume', + 'state': '40', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.aqara_hub_1563_pairing_mode', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:lock-open', + 'original_name': 'Aqara Hub-1563 Pairing Mode', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_65536_65538', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Aqara Hub-1563 Pairing Mode', + 'icon': 'mdi:lock-open', + }), + 'entity_id': 'switch.aqara_hub_1563_pairing_mode', + 'state': 'off', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[aqara_switch] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.0', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Aqara', + 'model': 'AR004', + 'name': 'Programmable Switch', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '9', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.programmable_switch_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Programmable Switch Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_65537', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Programmable Switch Identify', + }), + 'entity_id': 'button.programmable_switch_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.programmable_switch_battery_sensor', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Programmable Switch Battery Sensor', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_5', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'Programmable Switch Battery Sensor', + 'icon': 'mdi:battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.programmable_switch_battery_sensor', + 'state': '100', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[arlo_baby] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Netgear, Inc', + 'model': 'ABC1000', + 'name': 'ArloBabyA0', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.10.931', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.arlobabya0_motion', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'ArloBabyA0 Motion', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_500', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'ArloBabyA0 Motion', + }), + 'entity_id': 'binary_sensor.arlobabya0_motion', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.arlobabya0_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'ArloBabyA0 Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'ArloBabyA0 Identify', + }), + 'entity_id': 'button.arlobabya0_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'camera', + 'entity_category': None, + 'entity_id': 'camera.arlobabya0', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'ArloBabyA0', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'ArloBabyA0', + 'supported_features': , + }), + 'entity_id': 'camera.arlobabya0', + 'state': 'idle', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.arlobabya0_nightlight', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'ArloBabyA0 Nightlight', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1100', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'ArloBabyA0 Nightlight', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'entity_id': 'light.arlobabya0_nightlight', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.arlobabya0_air_quality', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'ArloBabyA0 Air Quality', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_800_802', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'aqi', + 'friendly_name': 'ArloBabyA0 Air Quality', + 'state_class': , + }), + 'entity_id': 'sensor.arlobabya0_air_quality', + 'state': '1', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.arlobabya0_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'ArloBabyA0 Battery', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_700', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'ArloBabyA0 Battery', + 'icon': 'mdi:battery-80', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.arlobabya0_battery', + 'state': '82', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.arlobabya0_humidity', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'ArloBabyA0 Humidity', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_900', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'humidity', + 'friendly_name': 'ArloBabyA0 Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.arlobabya0_humidity', + 'state': '60.099998', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.arlobabya0_temperature', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'ArloBabyA0 Temperature', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1000', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'temperature', + 'friendly_name': 'ArloBabyA0 Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.arlobabya0_temperature', + 'state': '24.0', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.arlobabya0_mute', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:volume-mute', + 'original_name': 'ArloBabyA0 Mute', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_300_302', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'ArloBabyA0 Mute', + 'icon': 'mdi:volume-mute', + }), + 'entity_id': 'switch.arlobabya0_mute', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.arlobabya0_mute_2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:volume-mute', + 'original_name': 'ArloBabyA0 Mute', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_400_402', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'ArloBabyA0 Mute', + 'icon': 'mdi:volume-mute', + }), + 'entity_id': 'switch.arlobabya0_mute_2', + 'state': 'off', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[connectsense] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'ConnectSense', + 'model': 'CS-IWO', + 'name': 'InWall Outlet-0394DE', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.0.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.inwall_outlet_0394de_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'InWall Outlet-0394DE Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'InWall Outlet-0394DE Identify', + }), + 'entity_id': 'button.inwall_outlet_0394de_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inwall_outlet_0394de_current', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'InWall Outlet-0394DE Current', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_13_18', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'current', + 'friendly_name': 'InWall Outlet-0394DE Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.inwall_outlet_0394de_current', + 'state': '0.03', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inwall_outlet_0394de_current_2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'InWall Outlet-0394DE Current', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_25_30', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'current', + 'friendly_name': 'InWall Outlet-0394DE Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.inwall_outlet_0394de_current_2', + 'state': '0.05', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inwall_outlet_0394de_energy_kwh', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'InWall Outlet-0394DE Energy kWh', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_13_20', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'energy', + 'friendly_name': 'InWall Outlet-0394DE Energy kWh', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.inwall_outlet_0394de_energy_kwh', + 'state': '379.69299', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inwall_outlet_0394de_energy_kwh_2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'InWall Outlet-0394DE Energy kWh', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_25_32', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'energy', + 'friendly_name': 'InWall Outlet-0394DE Energy kWh', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.inwall_outlet_0394de_energy_kwh_2', + 'state': '175.85001', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inwall_outlet_0394de_power', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'InWall Outlet-0394DE Power', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_13_19', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'power', + 'friendly_name': 'InWall Outlet-0394DE Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.inwall_outlet_0394de_power', + 'state': '0.8', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inwall_outlet_0394de_power_2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'InWall Outlet-0394DE Power', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_25_31', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'power', + 'friendly_name': 'InWall Outlet-0394DE Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.inwall_outlet_0394de_power_2', + 'state': '0.8', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.inwall_outlet_0394de_outlet_a', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'InWall Outlet-0394DE Outlet A', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_13', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'InWall Outlet-0394DE Outlet A', + 'outlet_in_use': True, + }), + 'entity_id': 'switch.inwall_outlet_0394de_outlet_a', + 'state': 'on', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.inwall_outlet_0394de_outlet_b', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'InWall Outlet-0394DE Outlet B', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_25', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'InWall Outlet-0394DE Outlet B', + 'outlet_in_use': True, + }), + 'entity_id': 'switch.inwall_outlet_0394de_outlet_b', + 'state': 'on', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[ecobee3] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:4', + ]), + ]), + 'is_new': False, + 'manufacturer': 'ecobee Inc.', + 'model': 'REMOTE SENSOR', + 'name': 'Basement', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.0.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.basement', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Basement', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4_56', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'Basement', + }), + 'entity_id': 'binary_sensor.basement', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.basement_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Basement Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4_1_4101', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Basement Identify', + }), + 'entity_id': 'button.basement_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.basement_temperature', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Basement Temperature', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4_55', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'temperature', + 'friendly_name': 'Basement Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.basement_temperature', + 'state': '20.7', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'ecobee Inc.', + 'model': 'ecobee3', + 'name': 'HomeW', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '4.2.394', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.homew', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'HomeW', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_56', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'HomeW', + }), + 'entity_id': 'binary_sensor.homew', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.homew_2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'HomeW', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_57', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'occupancy', + 'friendly_name': 'HomeW', + }), + 'entity_id': 'binary_sensor.homew_2', + 'state': 'on', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.homew_clear_hold', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HomeW Clear Hold', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_16_48', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'HomeW Clear Hold', + }), + 'entity_id': 'button.homew_clear_hold', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.homew_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HomeW Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'HomeW Identify', + }), + 'entity_id': 'button.homew_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_humidity': 50, + 'max_temp': 33.3, + 'min_humidity': 20, + 'min_temp': 7.2, + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.homew', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HomeW', + 'platform': 'homekit_controller', + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_16', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'current_humidity': 34, + 'current_temperature': 21.8, + 'friendly_name': 'HomeW', + 'humidity': 36, + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_humidity': 50, + 'max_temp': 33.3, + 'min_humidity': 20, + 'min_temp': 7.2, + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, + 'temperature': 22.2, + }), + 'entity_id': 'climate.homew', + 'state': 'heat', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'home', + 'sleep', + 'away', + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.homew_current_mode', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HomeW Current Mode', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': 'ecobee_mode', + 'unique_id': '00:00:00:00:00:00_1_16_33', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'HomeW Current Mode', + 'options': list([ + 'home', + 'sleep', + 'away', + ]), + }), + 'entity_id': 'select.homew_current_mode', + 'state': 'home', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'celsius', + 'fahrenheit', + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.homew_temperature_display_units', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:thermometer', + 'original_name': 'HomeW Temperature Display Units', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': 'temperature_display_units', + 'unique_id': '00:00:00:00:00:00_1_16_21', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'HomeW Temperature Display Units', + 'icon': 'mdi:thermometer', + 'options': list([ + 'celsius', + 'fahrenheit', + ]), + }), + 'entity_id': 'select.homew_temperature_display_units', + 'state': 'fahrenheit', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.homew_current_humidity', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'HomeW Current Humidity', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_16_24', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'humidity', + 'friendly_name': 'HomeW Current Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.homew_current_humidity', + 'state': '34', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.homew_current_temperature', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'HomeW Current Temperature', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_16_19', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'temperature', + 'friendly_name': 'HomeW Current Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.homew_current_temperature', + 'state': '21.8', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:2', + ]), + ]), + 'is_new': False, + 'manufacturer': 'ecobee Inc.', + 'model': 'REMOTE SENSOR', + 'name': 'Kitchen', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.0.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.kitchen', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Kitchen', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_2_56', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'Kitchen', + }), + 'entity_id': 'binary_sensor.kitchen', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.kitchen_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Kitchen Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_2_1_2053', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Kitchen Identify', + }), + 'entity_id': 'button.kitchen_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.kitchen_temperature', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Kitchen Temperature', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_2_55', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'temperature', + 'friendly_name': 'Kitchen Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.kitchen_temperature', + 'state': '21.5', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:3', + ]), + ]), + 'is_new': False, + 'manufacturer': 'ecobee Inc.', + 'model': 'REMOTE SENSOR', + 'name': 'Porch', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.0.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.porch', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Porch', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_3_56', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'Porch', + }), + 'entity_id': 'binary_sensor.porch', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.porch_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Porch Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_3_1_3077', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Porch Identify', + }), + 'entity_id': 'button.porch_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.porch_temperature', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Porch Temperature', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_3_55', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'temperature', + 'friendly_name': 'Porch Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.porch_temperature', + 'state': '21', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[ecobee3_no_sensors] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'ecobee Inc.', + 'model': 'ecobee3', + 'name': 'HomeW', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '4.2.394', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.homew', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'HomeW', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_56', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'HomeW', + }), + 'entity_id': 'binary_sensor.homew', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.homew_2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'HomeW', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_57', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'occupancy', + 'friendly_name': 'HomeW', + }), + 'entity_id': 'binary_sensor.homew_2', + 'state': 'on', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.homew_clear_hold', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HomeW Clear Hold', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_16_48', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'HomeW Clear Hold', + }), + 'entity_id': 'button.homew_clear_hold', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.homew_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HomeW Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'HomeW Identify', + }), + 'entity_id': 'button.homew_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_humidity': 50, + 'max_temp': 33.3, + 'min_humidity': 20, + 'min_temp': 7.2, + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.homew', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HomeW', + 'platform': 'homekit_controller', + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_16', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'current_humidity': 34, + 'current_temperature': 21.8, + 'friendly_name': 'HomeW', + 'humidity': 36, + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_humidity': 50, + 'max_temp': 33.3, + 'min_humidity': 20, + 'min_temp': 7.2, + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, + 'temperature': 22.2, + }), + 'entity_id': 'climate.homew', + 'state': 'heat', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'home', + 'sleep', + 'away', + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.homew_current_mode', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HomeW Current Mode', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': 'ecobee_mode', + 'unique_id': '00:00:00:00:00:00_1_16_33', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'HomeW Current Mode', + 'options': list([ + 'home', + 'sleep', + 'away', + ]), + }), + 'entity_id': 'select.homew_current_mode', + 'state': 'home', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'celsius', + 'fahrenheit', + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.homew_temperature_display_units', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:thermometer', + 'original_name': 'HomeW Temperature Display Units', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': 'temperature_display_units', + 'unique_id': '00:00:00:00:00:00_1_16_21', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'HomeW Temperature Display Units', + 'icon': 'mdi:thermometer', + 'options': list([ + 'celsius', + 'fahrenheit', + ]), + }), + 'entity_id': 'select.homew_temperature_display_units', + 'state': 'fahrenheit', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.homew_current_humidity', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'HomeW Current Humidity', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_16_24', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'humidity', + 'friendly_name': 'HomeW Current Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.homew_current_humidity', + 'state': '34', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.homew_current_temperature', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'HomeW Current Temperature', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_16_19', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'temperature', + 'friendly_name': 'HomeW Current Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.homew_current_temperature', + 'state': '21.8', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[ecobee_501] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'ecobee Inc.', + 'model': 'ECB501', + 'name': 'My ecobee', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '4.7.340214', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.my_ecobee_motion', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'My ecobee Motion', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_56', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'My ecobee Motion', + }), + 'entity_id': 'binary_sensor.my_ecobee_motion', + 'state': 'on', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.my_ecobee_occupancy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'My ecobee Occupancy', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_57', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'occupancy', + 'friendly_name': 'My ecobee Occupancy', + }), + 'entity_id': 'binary_sensor.my_ecobee_occupancy', + 'state': 'on', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.my_ecobee_clear_hold', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'My ecobee Clear Hold', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_16_48', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'My ecobee Clear Hold', + }), + 'entity_id': 'button.my_ecobee_clear_hold', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.my_ecobee_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'My ecobee Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'My ecobee Identify', + }), + 'entity_id': 'button.my_ecobee_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + 'on', + 'auto', + ]), + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_humidity': 50, + 'max_temp': 33.3, + 'min_humidity': 20, + 'min_temp': 7.2, + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.my_ecobee', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'My ecobee', + 'platform': 'homekit_controller', + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_16', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'current_humidity': 55.0, + 'current_temperature': 21.3, + 'fan_mode': 'auto', + 'fan_modes': list([ + 'on', + 'auto', + ]), + 'friendly_name': 'My ecobee', + 'humidity': 36.0, + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_humidity': 50, + 'max_temp': 33.3, + 'min_humidity': 20, + 'min_temp': 7.2, + 'supported_features': , + 'target_temp_high': 25.6, + 'target_temp_low': 7.2, + 'temperature': None, + }), + 'entity_id': 'climate.my_ecobee', + 'state': 'heat_cool', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'home', + 'sleep', + 'away', + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.my_ecobee_current_mode', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'My ecobee Current Mode', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': 'ecobee_mode', + 'unique_id': '00:00:00:00:00:00_1_16_33', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'My ecobee Current Mode', + 'options': list([ + 'home', + 'sleep', + 'away', + ]), + }), + 'entity_id': 'select.my_ecobee_current_mode', + 'state': 'home', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'celsius', + 'fahrenheit', + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.my_ecobee_temperature_display_units', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:thermometer', + 'original_name': 'My ecobee Temperature Display Units', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': 'temperature_display_units', + 'unique_id': '00:00:00:00:00:00_1_16_21', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'My ecobee Temperature Display Units', + 'icon': 'mdi:thermometer', + 'options': list([ + 'celsius', + 'fahrenheit', + ]), + }), + 'entity_id': 'select.my_ecobee_temperature_display_units', + 'state': 'fahrenheit', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.my_ecobee_current_humidity', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'My ecobee Current Humidity', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_16_24', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'humidity', + 'friendly_name': 'My ecobee Current Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.my_ecobee_current_humidity', + 'state': '55.0', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.my_ecobee_current_temperature', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'My ecobee Current Temperature', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_16_19', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'temperature', + 'friendly_name': 'My ecobee Current Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.my_ecobee_current_temperature', + 'state': '21.3', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[ecobee_occupancy] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'ecobee Inc.', + 'model': 'ecobee Switch+', + 'name': 'Master Fan', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '4.5.130201', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.master_fan', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Master Fan', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_56', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'Master Fan', + }), + 'entity_id': 'binary_sensor.master_fan', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.master_fan_2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Master Fan', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_57', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'occupancy', + 'friendly_name': 'Master Fan', + }), + 'entity_id': 'binary_sensor.master_fan_2', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.master_fan_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Master Fan Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Master Fan Identify', + }), + 'entity_id': 'button.master_fan_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.master_fan_light_level', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Master Fan Light Level', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_27', + 'unit_of_measurement': 'lx', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'illuminance', + 'friendly_name': 'Master Fan Light Level', + 'state_class': , + 'unit_of_measurement': 'lx', + }), + 'entity_id': 'sensor.master_fan_light_level', + 'state': '0', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.master_fan_temperature', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Master Fan Temperature', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_55', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'temperature', + 'friendly_name': 'Master Fan Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.master_fan_temperature', + 'state': '25.6', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.master_fan', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Master Fan', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_16', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Master Fan', + }), + 'entity_id': 'switch.master_fan', + 'state': 'off', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[eve_degree] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.0.0', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Elgato', + 'model': 'Eve Degree 00AAA0000', + 'name': 'Eve Degree AA11', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.2.8', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.eve_degree_aa11_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Eve Degree AA11 Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_3', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Eve Degree AA11 Identify', + }), + 'entity_id': 'button.eve_degree_aa11_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'max': 9000, + 'min': -450, + 'mode': , + 'step': 1, + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.eve_degree_aa11_elevation', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:elevation-rise', + 'original_name': 'Eve Degree AA11 Elevation', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_30_33', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Eve Degree AA11 Elevation', + 'icon': 'mdi:elevation-rise', + 'max': 9000, + 'min': -450, + 'mode': , + 'step': 1, + }), + 'entity_id': 'number.eve_degree_aa11_elevation', + 'state': '0', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'celsius', + 'fahrenheit', + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.eve_degree_aa11_temperature_display_units', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:thermometer', + 'original_name': 'Eve Degree AA11 Temperature Display Units', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': 'temperature_display_units', + 'unique_id': '00:00:00:00:00:00_1_22_25', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Eve Degree AA11 Temperature Display Units', + 'icon': 'mdi:thermometer', + 'options': list([ + 'celsius', + 'fahrenheit', + ]), + }), + 'entity_id': 'select.eve_degree_aa11_temperature_display_units', + 'state': 'celsius', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.eve_degree_aa11_air_pressure', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Eve Degree AA11 Air Pressure', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_30_32', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'pressure', + 'friendly_name': 'Eve Degree AA11 Air Pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.eve_degree_aa11_air_pressure', + 'state': '1005.70001220703', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.eve_degree_aa11_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Eve Degree AA11 Battery', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_17', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'Eve Degree AA11 Battery', + 'icon': 'mdi:battery-60', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.eve_degree_aa11_battery', + 'state': '65', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.eve_degree_aa11_humidity', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Eve Degree AA11 Humidity', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_27', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'humidity', + 'friendly_name': 'Eve Degree AA11 Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.eve_degree_aa11_humidity', + 'state': '59.4818115234375', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.eve_degree_aa11_temperature', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Eve Degree AA11 Temperature', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_22', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'temperature', + 'friendly_name': 'Eve Degree AA11 Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.eve_degree_aa11_temperature', + 'state': '22.7719116210938', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[eve_energy] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.0.0', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Elgato', + 'model': 'Eve Energy 20EAO8601', + 'name': 'Eve Energy 50FF', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.2.9', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.eve_energy_50ff_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Eve Energy 50FF Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_3', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Eve Energy 50FF Identify', + }), + 'entity_id': 'button.eve_energy_50ff_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.eve_energy_50ff_amps', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Eve Energy 50FF Amps', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_28_33', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'current', + 'friendly_name': 'Eve Energy 50FF Amps', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.eve_energy_50ff_amps', + 'state': '0', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.eve_energy_50ff_energy_kwh', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Eve Energy 50FF Energy kWh', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_28_35', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'energy', + 'friendly_name': 'Eve Energy 50FF Energy kWh', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.eve_energy_50ff_energy_kwh', + 'state': '0.28999999165535', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.eve_energy_50ff_power', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Eve Energy 50FF Power', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_28_34', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'power', + 'friendly_name': 'Eve Energy 50FF Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.eve_energy_50ff_power', + 'state': '0', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.eve_energy_50ff_volts', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Eve Energy 50FF Volts', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_28_32', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'voltage', + 'friendly_name': 'Eve Energy 50FF Volts', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.eve_energy_50ff_volts', + 'state': '0.400000005960464', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.eve_energy_50ff', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Eve Energy 50FF', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_28', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Eve Energy 50FF', + 'outlet_in_use': True, + }), + 'entity_id': 'switch.eve_energy_50ff', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.eve_energy_50ff_lock_physical_controls', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:lock-open', + 'original_name': 'Eve Energy 50FF Lock Physical Controls', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_28_36', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Eve Energy 50FF Lock Physical Controls', + 'icon': 'mdi:lock-open', + }), + 'entity_id': 'switch.eve_energy_50ff_lock_physical_controls', + 'state': 'off', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[haa_fan] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'José A. Jiménez Campos', + 'model': 'RavenSystem HAA', + 'name': 'HAA-C718B3', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '5.0.18', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.haa_c718b3_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HAA-C718B3 Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_7', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'HAA-C718B3 Identify', + }), + 'entity_id': 'button.haa_c718b3_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.haa_c718b3_setup', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:cog', + 'original_name': 'HAA-C718B3 Setup', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1010_1012', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'HAA-C718B3 Setup', + 'icon': 'mdi:cog', + }), + 'entity_id': 'button.haa_c718b3_setup', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.haa_c718b3_update', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'HAA-C718B3 Update', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1010_1011', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'update', + 'friendly_name': 'HAA-C718B3 Update', + }), + 'entity_id': 'button.haa_c718b3_update', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': None, + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.haa_c718b3', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HAA-C718B3', + 'platform': 'homekit_controller', + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_8', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'HAA-C718B3', + 'percentage': 66, + 'percentage_step': 33.333333333333336, + 'preset_mode': None, + 'preset_modes': None, + 'supported_features': , + }), + 'entity_id': 'fan.haa_c718b3', + 'state': 'on', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:2', + ]), + ]), + 'is_new': False, + 'manufacturer': 'José A. Jiménez Campos', + 'model': 'RavenSystem HAA', + 'name': 'HAA-C718B3', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '5.0.18', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.haa_c718b3_identify_2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HAA-C718B3 Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_2_1_7', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'HAA-C718B3 Identify', + }), + 'entity_id': 'button.haa_c718b3_identify_2', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.haa_c718b3', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HAA-C718B3', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_2_8', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'HAA-C718B3', + }), + 'entity_id': 'switch.haa_c718b3', + 'state': 'off', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[home_assistant_bridge_fan] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:766313939', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Home Assistant', + 'model': 'Fan', + 'name': 'Ceiling Fan', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '0.104.0.dev0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.ceiling_fan_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Ceiling Fan Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_766313939_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Ceiling Fan Identify', + }), + 'entity_id': 'button.ceiling_fan_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': None, + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.ceiling_fan', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Ceiling Fan', + 'platform': 'homekit_controller', + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_766313939_8', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Ceiling Fan', + 'percentage': 0, + 'percentage_step': 1.0, + 'preset_mode': None, + 'preset_modes': None, + 'supported_features': , + }), + 'entity_id': 'fan.ceiling_fan', + 'state': 'off', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Home Assistant', + 'model': 'Bridge', + 'name': 'Home Assistant Bridge', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '0.104.0.dev0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.home_assistant_bridge_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Home Assistant Bridge Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Home Assistant Bridge Identify', + }), + 'entity_id': 'button.home_assistant_bridge_identify', + 'state': 'unknown', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1256851357', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Home Assistant', + 'model': 'Fan', + 'name': 'Living Room Fan', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '0.104.0.dev0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.living_room_fan_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Living Room Fan Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1256851357_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Living Room Fan Identify', + }), + 'entity_id': 'button.living_room_fan_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': None, + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.living_room_fan', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Living Room Fan', + 'platform': 'homekit_controller', + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1256851357_8', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'direction': 'forward', + 'friendly_name': 'Living Room Fan', + 'oscillating': False, + 'percentage': 0, + 'percentage_step': 1.0, + 'preset_mode': None, + 'preset_modes': None, + 'supported_features': , + }), + 'entity_id': 'fan.living_room_fan', + 'state': 'off', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[homespan_daikin_bridge] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.0.0', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Garzola Marco', + 'model': 'Daikin-fwec3a-esp32-homekit-bridge', + 'name': 'Air Conditioner', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.0.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.air_conditioner_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Air Conditioner Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Air Conditioner Identify', + }), + 'entity_id': 'button.air_conditioner_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + 'off', + 'low', + 'medium', + 'high', + ]), + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 32, + 'min_temp': 18, + 'target_temp_step': 0.5, + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.air_conditioner_slaveid_1', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Air Conditioner SlaveID 1', + 'platform': 'homekit_controller', + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_9', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'current_temperature': 27.9, + 'fan_mode': 'high', + 'fan_modes': list([ + 'off', + 'low', + 'medium', + 'high', + ]), + 'friendly_name': 'Air Conditioner SlaveID 1', + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 32, + 'min_temp': 18, + 'supported_features': , + 'target_temp_step': 0.5, + 'temperature': 24.5, + }), + 'entity_id': 'climate.air_conditioner_slaveid_1', + 'state': 'cool', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.air_conditioner_current_temperature', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Air Conditioner Current Temperature', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_9_11', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'temperature', + 'friendly_name': 'Air Conditioner Current Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.air_conditioner_current_temperature', + 'state': '27.9', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[hue_bridge] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:6623462395276914', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Philips', + 'model': 'LTW012', + 'name': 'Hue ambiance candle', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.46.13', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.hue_ambiance_candle_identify_4', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hue ambiance candle Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462395276914_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Hue ambiance candle Identify', + }), + 'entity_id': 'button.hue_ambiance_candle_identify_4', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6535, + 'max_mireds': 454, + 'min_color_temp_kelvin': 2202, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.hue_ambiance_candle_4', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hue ambiance candle', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462395276914_2816', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Hue ambiance candle', + 'max_color_temp_kelvin': 6535, + 'max_mireds': 454, + 'min_color_temp_kelvin': 2202, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'entity_id': 'light.hue_ambiance_candle_4', + 'state': 'off', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:6623462395276939', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Philips', + 'model': 'LTW012', + 'name': 'Hue ambiance candle', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.46.13', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.hue_ambiance_candle_identify_3', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hue ambiance candle Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462395276939_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Hue ambiance candle Identify', + }), + 'entity_id': 'button.hue_ambiance_candle_identify_3', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6535, + 'max_mireds': 454, + 'min_color_temp_kelvin': 2202, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.hue_ambiance_candle_3', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hue ambiance candle', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462395276939_2816', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Hue ambiance candle', + 'max_color_temp_kelvin': 6535, + 'max_mireds': 454, + 'min_color_temp_kelvin': 2202, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'entity_id': 'light.hue_ambiance_candle_3', + 'state': 'off', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:6623462403113447', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Philips', + 'model': 'LTW012', + 'name': 'Hue ambiance candle', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.46.13', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.hue_ambiance_candle_identify_2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hue ambiance candle Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462403113447_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Hue ambiance candle Identify', + }), + 'entity_id': 'button.hue_ambiance_candle_identify_2', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6535, + 'max_mireds': 454, + 'min_color_temp_kelvin': 2202, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.hue_ambiance_candle_2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hue ambiance candle', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462403113447_2816', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Hue ambiance candle', + 'max_color_temp_kelvin': 6535, + 'max_mireds': 454, + 'min_color_temp_kelvin': 2202, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'entity_id': 'light.hue_ambiance_candle_2', + 'state': 'off', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:6623462403233419', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Philips', + 'model': 'LTW012', + 'name': 'Hue ambiance candle', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.46.13', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.hue_ambiance_candle_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hue ambiance candle Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462403233419_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Hue ambiance candle Identify', + }), + 'entity_id': 'button.hue_ambiance_candle_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6535, + 'max_mireds': 454, + 'min_color_temp_kelvin': 2202, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.hue_ambiance_candle', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hue ambiance candle', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462403233419_2816', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Hue ambiance candle', + 'max_color_temp_kelvin': 6535, + 'max_mireds': 454, + 'min_color_temp_kelvin': 2202, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'entity_id': 'light.hue_ambiance_candle', + 'state': 'off', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:6623462412411853', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Philips', + 'model': 'LTW013', + 'name': 'Hue ambiance spot', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.46.13', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.hue_ambiance_spot_identify_2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hue ambiance spot Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462412411853_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Hue ambiance spot Identify', + }), + 'entity_id': 'button.hue_ambiance_spot_identify_2', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6535, + 'max_mireds': 454, + 'min_color_temp_kelvin': 2202, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.hue_ambiance_spot_2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hue ambiance spot', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462412411853_2816', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'brightness': 255.0, + 'color_mode': , + 'color_temp': 366, + 'color_temp_kelvin': 2732, + 'friendly_name': 'Hue ambiance spot', + 'hs_color': tuple( + 28.327, + 64.71, + ), + 'max_color_temp_kelvin': 6535, + 'max_mireds': 454, + 'min_color_temp_kelvin': 2202, + 'min_mireds': 153, + 'rgb_color': tuple( + 255, + 167, + 89, + ), + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + 'xy_color': tuple( + 0.524, + 0.387, + ), + }), + 'entity_id': 'light.hue_ambiance_spot_2', + 'state': 'on', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:6623462412413293', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Philips', + 'model': 'LTW013', + 'name': 'Hue ambiance spot', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.46.13', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.hue_ambiance_spot_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hue ambiance spot Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462412413293_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Hue ambiance spot Identify', + }), + 'entity_id': 'button.hue_ambiance_spot_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6535, + 'max_mireds': 454, + 'min_color_temp_kelvin': 2202, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.hue_ambiance_spot', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hue ambiance spot', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462412413293_2816', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'brightness': 255.0, + 'color_mode': , + 'color_temp': 366, + 'color_temp_kelvin': 2732, + 'friendly_name': 'Hue ambiance spot', + 'hs_color': tuple( + 28.327, + 64.71, + ), + 'max_color_temp_kelvin': 6535, + 'max_mireds': 454, + 'min_color_temp_kelvin': 2202, + 'min_mireds': 153, + 'rgb_color': tuple( + 255, + 167, + 89, + ), + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + 'xy_color': tuple( + 0.524, + 0.387, + ), + }), + 'entity_id': 'light.hue_ambiance_spot', + 'state': 'on', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:6623462389072572', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Philips', + 'model': 'RWL021', + 'name': 'Hue dimmer switch', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '45.1.17846', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.hue_dimmer_switch_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hue dimmer switch Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462389072572_1_22', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Hue dimmer switch Identify', + }), + 'entity_id': 'button.hue_dimmer_switch_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'single_press', + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.hue_dimmer_switch_button_1', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hue dimmer switch button 1', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': 'button', + 'unique_id': '00:00:00:00:00:00_6623462389072572_588410585088', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'button', + 'event_type': None, + 'event_types': list([ + 'single_press', + ]), + 'friendly_name': 'Hue dimmer switch button 1', + }), + 'entity_id': 'event.hue_dimmer_switch_button_1', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'single_press', + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.hue_dimmer_switch_button_2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hue dimmer switch button 2', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': 'button', + 'unique_id': '00:00:00:00:00:00_6623462389072572_588410650624', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'button', + 'event_type': None, + 'event_types': list([ + 'single_press', + ]), + 'friendly_name': 'Hue dimmer switch button 2', + }), + 'entity_id': 'event.hue_dimmer_switch_button_2', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'single_press', + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.hue_dimmer_switch_button_3', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hue dimmer switch button 3', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': 'button', + 'unique_id': '00:00:00:00:00:00_6623462389072572_588410716160', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'button', + 'event_type': None, + 'event_types': list([ + 'single_press', + ]), + 'friendly_name': 'Hue dimmer switch button 3', + }), + 'entity_id': 'event.hue_dimmer_switch_button_3', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'single_press', + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.hue_dimmer_switch_button_4', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hue dimmer switch button 4', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': 'button', + 'unique_id': '00:00:00:00:00:00_6623462389072572_588410781696', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'button', + 'event_type': None, + 'event_types': list([ + 'single_press', + ]), + 'friendly_name': 'Hue dimmer switch button 4', + }), + 'entity_id': 'event.hue_dimmer_switch_button_4', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.hue_dimmer_switch_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Hue dimmer switch battery', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462389072572_644245094400', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'Hue dimmer switch battery', + 'icon': 'mdi:battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.hue_dimmer_switch_battery', + 'state': '100', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:6623462378982941', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Philips', + 'model': 'LWB010', + 'name': 'Hue white lamp', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.46.13', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.hue_white_lamp_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hue white lamp Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462378982941_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Hue white lamp Identify', + }), + 'entity_id': 'button.hue_white_lamp_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.hue_white_lamp', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hue white lamp', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462378982941_2816', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Hue white lamp', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'entity_id': 'light.hue_white_lamp', + 'state': 'off', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:6623462378983942', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Philips', + 'model': 'LWB010', + 'name': 'Hue white lamp', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.46.13', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.hue_white_lamp_identify_2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hue white lamp Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462378983942_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Hue white lamp Identify', + }), + 'entity_id': 'button.hue_white_lamp_identify_2', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.hue_white_lamp_2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hue white lamp', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462378983942_2816', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Hue white lamp', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'entity_id': 'light.hue_white_lamp_2', + 'state': 'off', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:6623462379122122', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Philips', + 'model': 'LWB010', + 'name': 'Hue white lamp', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.46.13', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.hue_white_lamp_identify_4', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hue white lamp Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462379122122_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Hue white lamp Identify', + }), + 'entity_id': 'button.hue_white_lamp_identify_4', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.hue_white_lamp_4', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hue white lamp', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462379122122_2816', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Hue white lamp', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'entity_id': 'light.hue_white_lamp_4', + 'state': 'off', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:6623462379123707', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Philips', + 'model': 'LWB010', + 'name': 'Hue white lamp', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.46.13', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.hue_white_lamp_identify_3', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hue white lamp Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462379123707_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Hue white lamp Identify', + }), + 'entity_id': 'button.hue_white_lamp_identify_3', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.hue_white_lamp_3', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hue white lamp', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462379123707_2816', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Hue white lamp', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'entity_id': 'light.hue_white_lamp_3', + 'state': 'off', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:6623462383114163', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Philips', + 'model': 'LWB010', + 'name': 'Hue white lamp', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.46.13', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.hue_white_lamp_identify_7', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hue white lamp Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462383114163_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Hue white lamp Identify', + }), + 'entity_id': 'button.hue_white_lamp_identify_7', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.hue_white_lamp_7', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hue white lamp', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462383114163_2816', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Hue white lamp', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'entity_id': 'light.hue_white_lamp_7', + 'state': 'off', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:6623462383114193', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Philips', + 'model': 'LWB010', + 'name': 'Hue white lamp', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.46.13', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.hue_white_lamp_identify_6', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hue white lamp Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462383114193_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Hue white lamp Identify', + }), + 'entity_id': 'button.hue_white_lamp_identify_6', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.hue_white_lamp_6', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hue white lamp', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462383114193_2816', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Hue white lamp', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'entity_id': 'light.hue_white_lamp_6', + 'state': 'off', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:6623462385996792', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Philips', + 'model': 'LWB010', + 'name': 'Hue white lamp', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.46.13', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.hue_white_lamp_identify_5', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hue white lamp Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462385996792_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Hue white lamp Identify', + }), + 'entity_id': 'button.hue_white_lamp_identify_5', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.hue_white_lamp_5', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hue white lamp', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462385996792_2816', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Hue white lamp', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'entity_id': 'light.hue_white_lamp_5', + 'state': 'off', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Philips Lighting', + 'model': 'BSB002', + 'name': 'Philips hue - 482544', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.32.1932126170', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.philips_hue_482544_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Philips hue - 482544 Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_7', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Philips hue - 482544 Identify', + }), + 'entity_id': 'button.philips_hue_482544_identify', + 'state': 'unknown', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[koogeek_ls1] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Koogeek', + 'model': 'LS1', + 'name': 'Koogeek-LS1-20833F', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '2.2.15', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.koogeek_ls1_20833f_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Koogeek-LS1-20833F Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Koogeek-LS1-20833F Identify', + }), + 'entity_id': 'button.koogeek_ls1_20833f_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.koogeek_ls1_20833f_light_strip', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Koogeek-LS1-20833F Light Strip', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_7', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Koogeek-LS1-20833F Light Strip', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'entity_id': 'light.koogeek_ls1_20833f_light_strip', + 'state': 'off', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[koogeek_p1eu] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Koogeek', + 'model': 'P1EU', + 'name': 'Koogeek-P1-A00AA0', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '2.3.7', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.koogeek_p1_a00aa0_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Koogeek-P1-A00AA0 Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Koogeek-P1-A00AA0 Identify', + }), + 'entity_id': 'button.koogeek_p1_a00aa0_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.koogeek_p1_a00aa0_power', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Koogeek-P1-A00AA0 Power', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_21_22', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'power', + 'friendly_name': 'Koogeek-P1-A00AA0 Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.koogeek_p1_a00aa0_power', + 'state': '5', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.koogeek_p1_a00aa0_outlet', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Koogeek-P1-A00AA0 outlet', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_7', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Koogeek-P1-A00AA0 outlet', + 'outlet_in_use': True, + }), + 'entity_id': 'switch.koogeek_p1_a00aa0_outlet', + 'state': 'off', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[koogeek_sw2] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Koogeek', + 'model': 'KH02CN', + 'name': 'Koogeek-SW2-187A91', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.0.3', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.koogeek_sw2_187a91_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Koogeek-SW2-187A91 Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Koogeek-SW2-187A91 Identify', + }), + 'entity_id': 'button.koogeek_sw2_187a91_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.koogeek_sw2_187a91_power', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Koogeek-SW2-187A91 Power', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_14_18', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'power', + 'friendly_name': 'Koogeek-SW2-187A91 Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.koogeek_sw2_187a91_power', + 'state': '0', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.koogeek_sw2_187a91_switch_1', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Koogeek-SW2-187A91 Switch 1', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_8', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Koogeek-SW2-187A91 Switch 1', + }), + 'entity_id': 'switch.koogeek_sw2_187a91_switch_1', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.koogeek_sw2_187a91_switch_2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Koogeek-SW2-187A91 Switch 2', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_11', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Koogeek-SW2-187A91 Switch 2', + }), + 'entity_id': 'switch.koogeek_sw2_187a91_switch_2', + 'state': 'off', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[lennox_e30] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '3.0.XX', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Lennox', + 'model': 'E30 2B', + 'name': 'Lennox', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '3.40.XX', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.lennox_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Lennox Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Lennox Identify', + }), + 'entity_id': 'button.lennox_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 37, + 'min_temp': 4.5, + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.lennox', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Lennox', + 'platform': 'homekit_controller', + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_100', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'current_humidity': 34, + 'current_temperature': 20.5, + 'friendly_name': 'Lennox', + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 37, + 'min_temp': 4.5, + 'supported_features': , + 'target_temp_high': 29.5, + 'target_temp_low': 21, + 'temperature': None, + }), + 'entity_id': 'climate.lennox', + 'state': 'heat_cool', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'celsius', + 'fahrenheit', + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.lennox_temperature_display_units', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:thermometer', + 'original_name': 'Lennox Temperature Display Units', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': 'temperature_display_units', + 'unique_id': '00:00:00:00:00:00_1_100_105', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Lennox Temperature Display Units', + 'icon': 'mdi:thermometer', + 'options': list([ + 'celsius', + 'fahrenheit', + ]), + }), + 'entity_id': 'select.lennox_temperature_display_units', + 'state': 'celsius', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.lennox_current_humidity', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Lennox Current Humidity', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_100_107', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'humidity', + 'friendly_name': 'Lennox Current Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.lennox_current_humidity', + 'state': '34', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.lennox_current_temperature', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Lennox Current Temperature', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_100_103', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'temperature', + 'friendly_name': 'Lennox Current Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.lennox_current_temperature', + 'state': '20.5', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[lg_tv] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'LG Electronics', + 'model': 'OLED55B9PUA', + 'name': 'LG webOS TV AF80', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '04.71.04', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.lg_webos_tv_af80_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'LG webOS TV AF80 Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'LG webOS TV AF80 Identify', + }), + 'entity_id': 'button.lg_webos_tv_af80_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'source_list': list([ + 'AirPlay', + 'Live TV', + 'HDMI 1', + 'Sony', + 'Apple', + 'AV', + 'HDMI 4', + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.lg_webos_tv_af80', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'LG webOS TV AF80', + 'platform': 'homekit_controller', + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_48', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'tv', + 'friendly_name': 'LG webOS TV AF80', + 'source': 'HDMI 4', + 'source_list': list([ + 'AirPlay', + 'Live TV', + 'HDMI 1', + 'Sony', + 'Apple', + 'AV', + 'HDMI 4', + ]), + 'supported_features': , + }), + 'entity_id': 'media_player.lg_webos_tv_af80', + 'state': 'on', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.lg_webos_tv_af80_mute', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:volume-mute', + 'original_name': 'LG webOS TV AF80 Mute', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_80_82', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'LG webOS TV AF80 Mute', + 'icon': 'mdi:volume-mute', + }), + 'entity_id': 'switch.lg_webos_tv_af80_mute', + 'state': 'off', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[lutron_caseta_bridge] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:21474836482', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Lutron Electronics Co., Inc', + 'model': 'PD-FSQN-XX', + 'name': 'Caséta® Wireless Fan Speed Control', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '001.005', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.caseta_r_wireless_fan_speed_control_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Caséta® Wireless Fan Speed Control Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_21474836482_1_85899345921', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Caséta® Wireless Fan Speed Control Identify', + }), + 'entity_id': 'button.caseta_r_wireless_fan_speed_control_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': None, + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.caseta_r_wireless_fan_speed_control', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Caséta® Wireless Fan Speed Control', + 'platform': 'homekit_controller', + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_21474836482_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Caséta® Wireless Fan Speed Control', + 'percentage': 0, + 'percentage_step': 25.0, + 'preset_mode': None, + 'preset_modes': None, + 'supported_features': , + }), + 'entity_id': 'fan.caseta_r_wireless_fan_speed_control', + 'state': 'off', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Lutron Electronics Co., Inc', + 'model': 'L-BDG2-WH', + 'name': 'Smart Bridge 2', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '08.08', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.smart_bridge_2_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Smart Bridge 2 Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_85899345921', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Smart Bridge 2 Identify', + }), + 'entity_id': 'button.smart_bridge_2_identify', + 'state': 'unknown', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[mss425f] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '4.0.0', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Meross', + 'model': 'MSS425F', + 'name': 'MSS425F-15cc', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '4.2.3', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.mss425f_15cc_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'MSS425F-15cc Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'MSS425F-15cc Identify', + }), + 'entity_id': 'button.mss425f_15cc_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.mss425f_15cc_outlet_1', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'MSS425F-15cc Outlet-1', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_12', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'MSS425F-15cc Outlet-1', + }), + 'entity_id': 'switch.mss425f_15cc_outlet_1', + 'state': 'on', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.mss425f_15cc_outlet_2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'MSS425F-15cc Outlet-2', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_15', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'MSS425F-15cc Outlet-2', + }), + 'entity_id': 'switch.mss425f_15cc_outlet_2', + 'state': 'on', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.mss425f_15cc_outlet_3', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'MSS425F-15cc Outlet-3', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_18', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'MSS425F-15cc Outlet-3', + }), + 'entity_id': 'switch.mss425f_15cc_outlet_3', + 'state': 'on', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.mss425f_15cc_outlet_4', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'MSS425F-15cc Outlet-4', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_21', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'MSS425F-15cc Outlet-4', + }), + 'entity_id': 'switch.mss425f_15cc_outlet_4', + 'state': 'on', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.mss425f_15cc_usb', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'MSS425F-15cc USB', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_24', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'MSS425F-15cc USB', + }), + 'entity_id': 'switch.mss425f_15cc_usb', + 'state': 'on', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[mss565] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '4.0.0', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Meross', + 'model': 'MSS565', + 'name': 'MSS565-28da', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '4.1.9', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.mss565_28da_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'MSS565-28da Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'MSS565-28da Identify', + }), + 'entity_id': 'button.mss565_28da_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.mss565_28da_dimmer_switch', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'MSS565-28da Dimmer Switch', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_12', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'brightness': 170.85, + 'color_mode': , + 'friendly_name': 'MSS565-28da Dimmer Switch', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'entity_id': 'light.mss565_28da_dimmer_switch', + 'state': 'on', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[mysa_living] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Empowered Homes Inc.', + 'model': 'v1', + 'name': 'Mysa-85dda9', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '2.8.1', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.mysa_85dda9_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Mysa-85dda9 Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Mysa-85dda9 Identify', + }), + 'entity_id': 'button.mysa_85dda9_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.mysa_85dda9_thermostat', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Mysa-85dda9 Thermostat', + 'platform': 'homekit_controller', + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_20', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'current_humidity': 40, + 'current_temperature': 24.1, + 'friendly_name': 'Mysa-85dda9 Thermostat', + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'supported_features': , + 'temperature': None, + }), + 'entity_id': 'climate.mysa_85dda9_thermostat', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.mysa_85dda9_display', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Mysa-85dda9 Display', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_40', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Mysa-85dda9 Display', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'entity_id': 'light.mysa_85dda9_display', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'celsius', + 'fahrenheit', + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.mysa_85dda9_temperature_display_units', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:thermometer', + 'original_name': 'Mysa-85dda9 Temperature Display Units', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': 'temperature_display_units', + 'unique_id': '00:00:00:00:00:00_1_20_26', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Mysa-85dda9 Temperature Display Units', + 'icon': 'mdi:thermometer', + 'options': list([ + 'celsius', + 'fahrenheit', + ]), + }), + 'entity_id': 'select.mysa_85dda9_temperature_display_units', + 'state': 'celsius', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mysa_85dda9_current_humidity', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Mysa-85dda9 Current Humidity', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_20_27', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'humidity', + 'friendly_name': 'Mysa-85dda9 Current Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.mysa_85dda9_current_humidity', + 'state': '40', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mysa_85dda9_current_temperature', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Mysa-85dda9 Current Temperature', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_20_25', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'temperature', + 'friendly_name': 'Mysa-85dda9 Current Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.mysa_85dda9_current_temperature', + 'state': '24.1', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[nanoleaf_strip_nl55] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.2.4', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Nanoleaf', + 'model': 'NL55', + 'name': 'Nanoleaf Strip 3B32', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.4.40', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.nanoleaf_strip_3b32_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Nanoleaf Strip 3B32 Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Nanoleaf Strip 3B32 Identify', + }), + 'entity_id': 'button.nanoleaf_strip_3b32_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.nanoleaf_strip_3b32_provision_preferred_thread_credentials', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Nanoleaf Strip 3B32 Provision Preferred Thread Credentials', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_31_119', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Nanoleaf Strip 3B32 Provision Preferred Thread Credentials', + }), + 'entity_id': 'button.nanoleaf_strip_3b32_provision_preferred_thread_credentials', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6535, + 'max_mireds': 470, + 'min_color_temp_kelvin': 2127, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + , + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.nanoleaf_strip_3b32_nanoleaf_light_strip', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Nanoleaf Strip 3B32 Nanoleaf Light Strip', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_19', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'brightness': 255.0, + 'color_mode': , + 'friendly_name': 'Nanoleaf Strip 3B32 Nanoleaf Light Strip', + 'hs_color': tuple( + 30.0, + 89.0, + ), + 'max_color_temp_kelvin': 6535, + 'max_mireds': 470, + 'min_color_temp_kelvin': 2127, + 'min_mireds': 153, + 'rgb_color': tuple( + 255, + 141, + 28, + ), + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': tuple( + 0.589, + 0.385, + ), + }), + 'entity_id': 'light.nanoleaf_strip_3b32_nanoleaf_light_strip', + 'state': 'on', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'border_router_capable', + 'full', + 'minimal', + 'none', + 'router_eligible', + 'sleepy', + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.nanoleaf_strip_3b32_thread_capabilities', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Nanoleaf Strip 3B32 Thread Capabilities', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': 'thread_node_capabilities', + 'unique_id': '00:00:00:00:00:00_1_31_115', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'enum', + 'friendly_name': 'Nanoleaf Strip 3B32 Thread Capabilities', + 'options': list([ + 'border_router_capable', + 'full', + 'minimal', + 'none', + 'router_eligible', + 'sleepy', + ]), + }), + 'entity_id': 'sensor.nanoleaf_strip_3b32_thread_capabilities', + 'state': 'border_router_capable', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'border_router', + 'child', + 'detached', + 'disabled', + 'joining', + 'leader', + 'router', + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.nanoleaf_strip_3b32_thread_status', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Nanoleaf Strip 3B32 Thread Status', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': 'thread_status', + 'unique_id': '00:00:00:00:00:00_1_31_117', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'enum', + 'friendly_name': 'Nanoleaf Strip 3B32 Thread Status', + 'options': list([ + 'border_router', + 'child', + 'detached', + 'disabled', + 'joining', + 'leader', + 'router', + ]), + }), + 'entity_id': 'sensor.nanoleaf_strip_3b32_thread_status', + 'state': 'border_router', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[netamo_doorbell] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Netatmo', + 'model': 'Netatmo Doorbell', + 'name': 'Netatmo-Doorbell-g738658', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '80.0.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.netatmo_doorbell_g738658_motion_sensor', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Netatmo-Doorbell-g738658 Motion Sensor', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_10', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'Netatmo-Doorbell-g738658 Motion Sensor', + }), + 'entity_id': 'binary_sensor.netatmo_doorbell_g738658_motion_sensor', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.netatmo_doorbell_g738658_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Netatmo-Doorbell-g738658 Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_7', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Netatmo-Doorbell-g738658 Identify', + }), + 'entity_id': 'button.netatmo_doorbell_g738658_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'camera', + 'entity_category': None, + 'entity_id': 'camera.netatmo_doorbell_g738658', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Netatmo-Doorbell-g738658', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Netatmo-Doorbell-g738658', + 'supported_features': , + }), + 'entity_id': 'camera.netatmo_doorbell_g738658', + 'state': 'idle', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'single_press', + 'double_press', + 'long_press', + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.netatmo_doorbell_g738658', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Netatmo-Doorbell-g738658', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': 'doorbell', + 'unique_id': '00:00:00:00:00:00_1_49', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'doorbell', + 'event_type': None, + 'event_types': list([ + 'single_press', + 'double_press', + 'long_press', + ]), + 'friendly_name': 'Netatmo-Doorbell-g738658', + }), + 'entity_id': 'event.netatmo_doorbell_g738658', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.netatmo_doorbell_g738658_mute', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:volume-mute', + 'original_name': 'Netatmo-Doorbell-g738658 Mute', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_51_52', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Netatmo-Doorbell-g738658 Mute', + 'icon': 'mdi:volume-mute', + }), + 'entity_id': 'switch.netatmo_doorbell_g738658_mute', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.netatmo_doorbell_g738658_mute_2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:volume-mute', + 'original_name': 'Netatmo-Doorbell-g738658 Mute', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_8_9', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Netatmo-Doorbell-g738658 Mute', + 'icon': 'mdi:volume-mute', + }), + 'entity_id': 'switch.netatmo_doorbell_g738658_mute_2', + 'state': 'off', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[netamo_smart_co_alarm] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '0', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Netatmo', + 'model': 'Smart CO Alarm', + 'name': 'Smart CO Alarm', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.0.3', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.smart_co_alarm_carbon_monoxide_sensor', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Smart CO Alarm Carbon Monoxide Sensor', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_22', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'carbon_monoxide', + 'friendly_name': 'Smart CO Alarm Carbon Monoxide Sensor', + }), + 'entity_id': 'binary_sensor.smart_co_alarm_carbon_monoxide_sensor', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.smart_co_alarm_low_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Smart CO Alarm Low Battery', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_36', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'Smart CO Alarm Low Battery', + }), + 'entity_id': 'binary_sensor.smart_co_alarm_low_battery', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.smart_co_alarm_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Smart CO Alarm Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_7_3', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Smart CO Alarm Identify', + }), + 'entity_id': 'button.smart_co_alarm_identify', + 'state': 'unknown', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[netatmo_home_coach] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Netatmo', + 'model': 'Healthy Home Coach', + 'name': 'Healthy Home Coach', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '59', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.healthy_home_coach_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Healthy Home Coach Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_7', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Healthy Home Coach Identify', + }), + 'entity_id': 'button.healthy_home_coach_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.healthy_home_coach_air_quality', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Healthy Home Coach Air Quality', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_24_8', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'aqi', + 'friendly_name': 'Healthy Home Coach Air Quality', + 'state_class': , + }), + 'entity_id': 'sensor.healthy_home_coach_air_quality', + 'state': '1', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.healthy_home_coach_carbon_dioxide_sensor', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Healthy Home Coach Carbon Dioxide sensor', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_10', + 'unit_of_measurement': 'ppm', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'carbon_dioxide', + 'friendly_name': 'Healthy Home Coach Carbon Dioxide sensor', + 'state_class': , + 'unit_of_measurement': 'ppm', + }), + 'entity_id': 'sensor.healthy_home_coach_carbon_dioxide_sensor', + 'state': '804', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.healthy_home_coach_humidity_sensor', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Healthy Home Coach Humidity sensor', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_14', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'humidity', + 'friendly_name': 'Healthy Home Coach Humidity sensor', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.healthy_home_coach_humidity_sensor', + 'state': '59', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.healthy_home_coach_noise', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Healthy Home Coach Noise', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_20_21', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'sound_pressure', + 'friendly_name': 'Healthy Home Coach Noise', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.healthy_home_coach_noise', + 'state': '0', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.healthy_home_coach_temperature_sensor', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Healthy Home Coach Temperature sensor', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_17', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'temperature', + 'friendly_name': 'Healthy Home Coach Temperature sensor', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.healthy_home_coach_temperature_sensor', + 'state': '22.9', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[rainmachine-pro-8] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Green Electronics LLC', + 'model': 'SPK5 Pro', + 'name': 'RainMachine-00ce4a', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.0.4', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.rainmachine_00ce4a_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'RainMachine-00ce4a Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'RainMachine-00ce4a Identify', + }), + 'entity_id': 'button.rainmachine_00ce4a_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.rainmachine_00ce4a', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:water', + 'original_name': 'RainMachine-00ce4a', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_512', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'RainMachine-00ce4a', + 'icon': 'mdi:water', + 'in_use': False, + 'is_configured': True, + 'remaining_duration': 0, + }), + 'entity_id': 'switch.rainmachine_00ce4a', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.rainmachine_00ce4a_2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:water', + 'original_name': 'RainMachine-00ce4a', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_768', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'RainMachine-00ce4a', + 'icon': 'mdi:water', + 'in_use': False, + 'is_configured': True, + 'remaining_duration': 0, + }), + 'entity_id': 'switch.rainmachine_00ce4a_2', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.rainmachine_00ce4a_3', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:water', + 'original_name': 'RainMachine-00ce4a', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1024', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'RainMachine-00ce4a', + 'icon': 'mdi:water', + 'in_use': False, + 'is_configured': True, + 'remaining_duration': 0, + }), + 'entity_id': 'switch.rainmachine_00ce4a_3', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.rainmachine_00ce4a_4', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:water', + 'original_name': 'RainMachine-00ce4a', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1280', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'RainMachine-00ce4a', + 'icon': 'mdi:water', + 'in_use': False, + 'is_configured': True, + 'remaining_duration': 0, + }), + 'entity_id': 'switch.rainmachine_00ce4a_4', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.rainmachine_00ce4a_5', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:water', + 'original_name': 'RainMachine-00ce4a', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1536', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'RainMachine-00ce4a', + 'icon': 'mdi:water', + 'in_use': False, + 'is_configured': True, + 'remaining_duration': 0, + }), + 'entity_id': 'switch.rainmachine_00ce4a_5', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.rainmachine_00ce4a_6', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:water', + 'original_name': 'RainMachine-00ce4a', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1792', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'RainMachine-00ce4a', + 'icon': 'mdi:water', + 'in_use': False, + 'is_configured': True, + 'remaining_duration': 0, + }), + 'entity_id': 'switch.rainmachine_00ce4a_6', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.rainmachine_00ce4a_7', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:water', + 'original_name': 'RainMachine-00ce4a', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_2048', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'RainMachine-00ce4a', + 'icon': 'mdi:water', + 'in_use': False, + 'is_configured': True, + 'remaining_duration': 0, + }), + 'entity_id': 'switch.rainmachine_00ce4a_7', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.rainmachine_00ce4a_8', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:water', + 'original_name': 'RainMachine-00ce4a', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_2304', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'RainMachine-00ce4a', + 'icon': 'mdi:water', + 'in_use': False, + 'is_configured': True, + 'remaining_duration': 0, + }), + 'entity_id': 'switch.rainmachine_00ce4a_8', + 'state': 'off', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[ryse_smart_bridge] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.0.0', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:2', + ]), + ]), + 'is_new': False, + 'manufacturer': 'RYSE Inc.', + 'model': 'RYSE Shade', + 'name': 'Master Bath South', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '3.0.8', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.master_bath_south_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Master Bath South Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_2_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Master Bath South Identify', + }), + 'entity_id': 'button.master_bath_south_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.master_bath_south_ryse_shade', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Master Bath South RYSE Shade', + 'platform': 'homekit_controller', + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_2_48', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'current_position': 0, + 'friendly_name': 'Master Bath South RYSE Shade', + 'supported_features': , + }), + 'entity_id': 'cover.master_bath_south_ryse_shade', + 'state': 'closed', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.master_bath_south_ryse_shade_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Master Bath South RYSE Shade Battery', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_2_64', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'Master Bath South RYSE Shade Battery', + 'icon': 'mdi:battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.master_bath_south_ryse_shade_battery', + 'state': '100', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '0101.3521.0436', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'RYSE Inc.', + 'model': 'RYSE SmartBridge', + 'name': 'RYSE SmartBridge', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.3.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.ryse_smartbridge_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'RYSE SmartBridge Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'RYSE SmartBridge Identify', + }), + 'entity_id': 'button.ryse_smartbridge_identify', + 'state': 'unknown', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:3', + ]), + ]), + 'is_new': False, + 'manufacturer': 'RYSE Inc.', + 'model': 'RYSE Shade', + 'name': 'RYSE SmartShade', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.ryse_smartshade_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'RYSE SmartShade Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_3_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'RYSE SmartShade Identify', + }), + 'entity_id': 'button.ryse_smartshade_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.ryse_smartshade_ryse_shade', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'RYSE SmartShade RYSE Shade', + 'platform': 'homekit_controller', + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_3_48', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'current_position': 100, + 'friendly_name': 'RYSE SmartShade RYSE Shade', + 'supported_features': , + }), + 'entity_id': 'cover.ryse_smartshade_ryse_shade', + 'state': 'open', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.ryse_smartshade_ryse_shade_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'RYSE SmartShade RYSE Shade Battery', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_3_64', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'RYSE SmartShade RYSE Shade Battery', + 'icon': 'mdi:battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.ryse_smartshade_ryse_shade_battery', + 'state': '100', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[ryse_smart_bridge_four_shades] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.0.0', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:4', + ]), + ]), + 'is_new': False, + 'manufacturer': 'RYSE Inc.', + 'model': 'RYSE Shade', + 'name': 'BR Left', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '3.0.8', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.br_left_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'BR Left Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'BR Left Identify', + }), + 'entity_id': 'button.br_left_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.br_left_ryse_shade', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'BR Left RYSE Shade', + 'platform': 'homekit_controller', + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4_48', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'current_position': 100, + 'friendly_name': 'BR Left RYSE Shade', + 'supported_features': , + }), + 'entity_id': 'cover.br_left_ryse_shade', + 'state': 'open', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.br_left_ryse_shade_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'BR Left RYSE Shade Battery', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4_64', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'BR Left RYSE Shade Battery', + 'icon': 'mdi:battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.br_left_ryse_shade_battery', + 'state': '100', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.0.0', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:2', + ]), + ]), + 'is_new': False, + 'manufacturer': 'RYSE Inc.', + 'model': 'RYSE Shade', + 'name': 'LR Left', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '3.0.8', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.lr_left_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'LR Left Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_2_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'LR Left Identify', + }), + 'entity_id': 'button.lr_left_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.lr_left_ryse_shade', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'LR Left RYSE Shade', + 'platform': 'homekit_controller', + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_2_48', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'current_position': 0, + 'friendly_name': 'LR Left RYSE Shade', + 'supported_features': , + }), + 'entity_id': 'cover.lr_left_ryse_shade', + 'state': 'closed', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.lr_left_ryse_shade_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'LR Left RYSE Shade Battery', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_2_64', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'LR Left RYSE Shade Battery', + 'icon': 'mdi:battery-90', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.lr_left_ryse_shade_battery', + 'state': '89', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.0.0', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:3', + ]), + ]), + 'is_new': False, + 'manufacturer': 'RYSE Inc.', + 'model': 'RYSE Shade', + 'name': 'LR Right', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '3.0.8', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.lr_right_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'LR Right Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_3_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'LR Right Identify', + }), + 'entity_id': 'button.lr_right_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.lr_right_ryse_shade', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'LR Right RYSE Shade', + 'platform': 'homekit_controller', + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_3_48', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'current_position': 0, + 'friendly_name': 'LR Right RYSE Shade', + 'supported_features': , + }), + 'entity_id': 'cover.lr_right_ryse_shade', + 'state': 'closed', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.lr_right_ryse_shade_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'LR Right RYSE Shade Battery', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_3_64', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'LR Right RYSE Shade Battery', + 'icon': 'mdi:battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.lr_right_ryse_shade_battery', + 'state': '100', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '0401.3521.0679', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'RYSE Inc.', + 'model': 'RYSE SmartBridge', + 'name': 'RYSE SmartBridge', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.3.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.ryse_smartbridge_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'RYSE SmartBridge Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'RYSE SmartBridge Identify', + }), + 'entity_id': 'button.ryse_smartbridge_identify', + 'state': 'unknown', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.0.0', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:5', + ]), + ]), + 'is_new': False, + 'manufacturer': 'RYSE Inc.', + 'model': 'RYSE Shade', + 'name': 'RZSS', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '3.0.8', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.rzss_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'RZSS Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_5_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'RZSS Identify', + }), + 'entity_id': 'button.rzss_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.rzss_ryse_shade', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'RZSS RYSE Shade', + 'platform': 'homekit_controller', + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_5_48', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'current_position': 100, + 'friendly_name': 'RZSS RYSE Shade', + 'supported_features': , + }), + 'entity_id': 'cover.rzss_ryse_shade', + 'state': 'open', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.rzss_ryse_shade_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'RZSS RYSE Shade Battery', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_5_64', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'RZSS RYSE Shade Battery', + 'icon': 'mdi:battery-alert', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.rzss_ryse_shade_battery', + 'state': '0', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[schlage_sense] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.3.0', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Schlage ', + 'model': 'BE479CAM619', + 'name': 'SENSE ', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '004.027.000', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.sense_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'SENSE Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_3', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'SENSE Identify', + }), + 'entity_id': 'button.sense_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'lock', + 'entity_category': None, + 'entity_id': 'lock.sense_lock_mechanism', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'SENSE Lock Mechanism', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_30', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'SENSE Lock Mechanism', + 'supported_features': , + }), + 'entity_id': 'lock.sense_lock_mechanism', + 'state': 'unknown', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[simpleconnect_fan] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Hunter Fan', + 'model': 'SIMPLEconnect', + 'name': 'SIMPLEconnect Fan-06F674', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.simpleconnect_fan_06f674_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'SIMPLEconnect Fan-06F674 Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'SIMPLEconnect Fan-06F674 Identify', + }), + 'entity_id': 'button.simpleconnect_fan_06f674_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': None, + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.simpleconnect_fan_06f674_hunter_fan', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'SIMPLEconnect Fan-06F674 Hunter Fan', + 'platform': 'homekit_controller', + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_8', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'direction': 'forward', + 'friendly_name': 'SIMPLEconnect Fan-06F674 Hunter Fan', + 'percentage': 0, + 'percentage_step': 25.0, + 'preset_mode': None, + 'preset_modes': None, + 'supported_features': , + }), + 'entity_id': 'fan.simpleconnect_fan_06f674_hunter_fan', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.simpleconnect_fan_06f674_hunter_light', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'SIMPLEconnect Fan-06F674 Hunter Light', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_29', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'brightness': 76.5, + 'color_mode': , + 'friendly_name': 'SIMPLEconnect Fan-06F674 Hunter Light', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'entity_id': 'light.simpleconnect_fan_06f674_hunter_light', + 'state': 'on', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[velux_gateway] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'VELUX', + 'model': 'VELUX Gateway', + 'name': 'VELUX Gateway', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '70', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.velux_gateway_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'VELUX Gateway Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'VELUX Gateway Identify', + }), + 'entity_id': 'button.velux_gateway_identify', + 'state': 'unknown', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:2', + ]), + ]), + 'is_new': False, + 'manufacturer': 'VELUX', + 'model': 'VELUX Sensor', + 'name': 'VELUX Sensor', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '16', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.velux_sensor_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'VELUX Sensor Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_2_1_7', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'VELUX Sensor Identify', + }), + 'entity_id': 'button.velux_sensor_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.velux_sensor_carbon_dioxide_sensor', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'VELUX Sensor Carbon Dioxide sensor', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_2_14', + 'unit_of_measurement': 'ppm', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'carbon_dioxide', + 'friendly_name': 'VELUX Sensor Carbon Dioxide sensor', + 'state_class': , + 'unit_of_measurement': 'ppm', + }), + 'entity_id': 'sensor.velux_sensor_carbon_dioxide_sensor', + 'state': '400', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.velux_sensor_humidity_sensor', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'VELUX Sensor Humidity sensor', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_2_11', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'humidity', + 'friendly_name': 'VELUX Sensor Humidity sensor', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.velux_sensor_humidity_sensor', + 'state': '58', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.velux_sensor_temperature_sensor', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'VELUX Sensor Temperature sensor', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_2_8', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'temperature', + 'friendly_name': 'VELUX Sensor Temperature sensor', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.velux_sensor_temperature_sensor', + 'state': '18.9', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:3', + ]), + ]), + 'is_new': False, + 'manufacturer': 'VELUX', + 'model': 'VELUX Window', + 'name': 'VELUX Window', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '48', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.velux_window_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'VELUX Window Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_3_1_7', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'VELUX Window Identify', + }), + 'entity_id': 'button.velux_window_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.velux_window_roof_window', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'VELUX Window Roof Window', + 'platform': 'homekit_controller', + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_3_8', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'current_position': 0, + 'device_class': 'window', + 'friendly_name': 'VELUX Window Roof Window', + 'supported_features': , + }), + 'entity_id': 'cover.velux_window_roof_window', + 'state': 'closed', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[vocolinc_flowerbud] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '0.1', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'VOCOlinc', + 'model': 'Flowerbud', + 'name': 'VOCOlinc-Flowerbud-0d324b', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '3.121.2', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.vocolinc_flowerbud_0d324b_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'VOCOlinc-Flowerbud-0d324b Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'VOCOlinc-Flowerbud-0d324b Identify', + }), + 'entity_id': 'button.vocolinc_flowerbud_0d324b_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'available_modes': list([ + 'normal', + 'auto', + ]), + 'max_humidity': 100, + 'min_humidity': 0, + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'humidifier', + 'entity_category': None, + 'entity_id': 'humidifier.vocolinc_flowerbud_0d324b', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'VOCOlinc-Flowerbud-0d324b', + 'platform': 'homekit_controller', + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_30', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'available_modes': list([ + 'normal', + 'auto', + ]), + 'current_humidity': 45.0, + 'device_class': 'humidifier', + 'friendly_name': 'VOCOlinc-Flowerbud-0d324b', + 'humidity': 100.0, + 'max_humidity': 100, + 'min_humidity': 0, + 'mode': 'normal', + 'supported_features': , + }), + 'entity_id': 'humidifier.vocolinc_flowerbud_0d324b', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.vocolinc_flowerbud_0d324b_mood_light', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'VOCOlinc-Flowerbud-0d324b Mood Light', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_9', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'brightness': 127.5, + 'color_mode': , + 'friendly_name': 'VOCOlinc-Flowerbud-0d324b Mood Light', + 'hs_color': tuple( + 120.0, + 100.0, + ), + 'rgb_color': tuple( + 0, + 255, + 0, + ), + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + 'xy_color': tuple( + 0.172, + 0.747, + ), + }), + 'entity_id': 'light.vocolinc_flowerbud_0d324b_mood_light', + 'state': 'on', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'max': 5, + 'min': 1, + 'mode': , + 'step': 1, + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.vocolinc_flowerbud_0d324b_spray_quantity', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:water', + 'original_name': 'VOCOlinc-Flowerbud-0d324b Spray Quantity', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_30_38', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'VOCOlinc-Flowerbud-0d324b Spray Quantity', + 'icon': 'mdi:water', + 'max': 5, + 'min': 1, + 'mode': , + 'step': 1, + }), + 'entity_id': 'number.vocolinc_flowerbud_0d324b_spray_quantity', + 'state': '5', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.vocolinc_flowerbud_0d324b_current_humidity', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'VOCOlinc-Flowerbud-0d324b Current Humidity', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_30_33', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'humidity', + 'friendly_name': 'VOCOlinc-Flowerbud-0d324b Current Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.vocolinc_flowerbud_0d324b_current_humidity', + 'state': '45.0', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[vocolinc_vp3] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.0.3', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'VOCOlinc', + 'model': 'VP3', + 'name': 'VOCOlinc-VP3-123456', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.101.2', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.vocolinc_vp3_123456_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'VOCOlinc-VP3-123456 Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'VOCOlinc-VP3-123456 Identify', + }), + 'entity_id': 'button.vocolinc_vp3_123456_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.vocolinc_vp3_123456_power', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'VOCOlinc-VP3-123456 Power', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_48_97', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'power', + 'friendly_name': 'VOCOlinc-VP3-123456 Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.vocolinc_vp3_123456_power', + 'state': '0', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.vocolinc_vp3_123456_outlet', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'VOCOlinc-VP3-123456 Outlet', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_48', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'VOCOlinc-VP3-123456 Outlet', + }), + 'entity_id': 'switch.vocolinc_vp3_123456_outlet', + 'state': 'on', + }), + }), + ]), + }), + ]) +# --- diff --git a/tests/components/homekit_controller/specific_devices/test_airversa_ap2.py b/tests/components/homekit_controller/specific_devices/test_airversa_ap2.py deleted file mode 100644 index 0091fc098de..00000000000 --- a/tests/components/homekit_controller/specific_devices/test_airversa_ap2.py +++ /dev/null @@ -1,122 +0,0 @@ -"""Tests for Airversa AP2 Air Purifier.""" -from homeassistant.components.sensor import SensorStateClass -from homeassistant.const import CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, PERCENTAGE -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import EntityCategory - -from ..common import ( - HUB_TEST_ACCESSORY_ID, - DeviceTestInfo, - EntityTestInfo, - assert_devices_and_entities_created, - setup_accessories_from_file, - setup_test_accessories, -) - - -async def test_airversa_ap2_setup(hass: HomeAssistant) -> None: - """Test that an Ecbobee occupancy sensor be correctly setup in HA.""" - accessories = await setup_accessories_from_file(hass, "airversa_ap2.json") - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="Airversa AP2 1808", - model="AP2", - manufacturer="Sleekpoint Innovations", - sw_version="0.8.16", - hw_version="0.1", - serial_number="1234", - devices=[], - entities=[ - EntityTestInfo( - entity_id="switch.airversa_ap2_1808_lock_physical_controls", - friendly_name="Airversa AP2 1808 Lock Physical Controls", - unique_id="00:00:00:00:00:00_1_32832_32839", - entity_category=EntityCategory.CONFIG, - state="off", - ), - EntityTestInfo( - entity_id="switch.airversa_ap2_1808_mute", - friendly_name="Airversa AP2 1808 Mute", - unique_id="00:00:00:00:00:00_1_32832_32843", - entity_category=EntityCategory.CONFIG, - state="on", - ), - EntityTestInfo( - entity_id="switch.airversa_ap2_1808_sleep_mode", - friendly_name="Airversa AP2 1808 Sleep Mode", - unique_id="00:00:00:00:00:00_1_32832_32842", - entity_category=EntityCategory.CONFIG, - state="off", - ), - EntityTestInfo( - entity_id="sensor.airversa_ap2_1808_air_quality", - friendly_name="Airversa AP2 1808 Air Quality", - unique_id="00:00:00:00:00:00_1_2576_2579", - state="1", - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - ), - EntityTestInfo( - entity_id="sensor.airversa_ap2_1808_filter_lifetime", - friendly_name="Airversa AP2 1808 Filter lifetime", - unique_id="00:00:00:00:00:00_1_32896_32900", - state="100.0", - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unit_of_measurement=PERCENTAGE, - ), - EntityTestInfo( - entity_id="sensor.airversa_ap2_1808_pm2_5_density", - friendly_name="Airversa AP2 1808 PM2.5 Density", - unique_id="00:00:00:00:00:00_1_2576_2580", - state="3.0", - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - ), - EntityTestInfo( - entity_id="sensor.airversa_ap2_1808_thread_capabilities", - friendly_name="Airversa AP2 1808 Thread Capabilities", - unique_id="00:00:00:00:00:00_1_112_115", - state="router_eligible", - entity_category=EntityCategory.DIAGNOSTIC, - capabilities={ - "options": [ - "border_router_capable", - "full", - "minimal", - "none", - "router_eligible", - "sleepy", - ] - }, - ), - EntityTestInfo( - entity_id="sensor.airversa_ap2_1808_thread_status", - friendly_name="Airversa AP2 1808 Thread Status", - unique_id="00:00:00:00:00:00_1_112_117", - state="router", - entity_category=EntityCategory.DIAGNOSTIC, - capabilities={ - "options": [ - "border_router", - "child", - "detached", - "disabled", - "joining", - "leader", - "router", - ] - }, - ), - EntityTestInfo( - entity_id="button.airversa_ap2_1808_identify", - friendly_name="Airversa AP2 1808 Identify", - unique_id="00:00:00:00:00:00_1_1_2", - entity_category=EntityCategory.DIAGNOSTIC, - state="unknown", - ), - ], - ), - ) diff --git a/tests/components/homekit_controller/specific_devices/test_aqara_gateway.py b/tests/components/homekit_controller/specific_devices/test_aqara_gateway.py deleted file mode 100644 index 30ecc298d40..00000000000 --- a/tests/components/homekit_controller/specific_devices/test_aqara_gateway.py +++ /dev/null @@ -1,127 +0,0 @@ -"""Regression tests for Aqara Gateway V3. - -https://github.com/home-assistant/core/issues/20957 -""" -from homeassistant.components.alarm_control_panel import AlarmControlPanelEntityFeature -from homeassistant.components.number import NumberMode -from homeassistant.const import EntityCategory -from homeassistant.core import HomeAssistant - -from ..common import ( - HUB_TEST_ACCESSORY_ID, - DeviceTestInfo, - EntityTestInfo, - assert_devices_and_entities_created, - setup_accessories_from_file, - setup_test_accessories, -) - - -async def test_aqara_gateway_setup(hass: HomeAssistant) -> None: - """Test that a Aqara Gateway can be correctly setup in HA.""" - accessories = await setup_accessories_from_file(hass, "aqara_gateway.json") - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="Aqara Hub-1563", - model="ZHWA11LM", - manufacturer="Aqara", - sw_version="1.4.7", - hw_version="", - serial_number="0000000123456789", - devices=[], - entities=[ - EntityTestInfo( - "alarm_control_panel.aqara_hub_1563_security_system", - friendly_name="Aqara Hub-1563 Security System", - unique_id="00:00:00:00:00:00_1_66304", - supported_features=AlarmControlPanelEntityFeature.ARM_NIGHT - | AlarmControlPanelEntityFeature.ARM_HOME - | AlarmControlPanelEntityFeature.ARM_AWAY, - state="disarmed", - ), - EntityTestInfo( - "light.aqara_hub_1563_lightbulb_1563", - friendly_name="Aqara Hub-1563 Lightbulb-1563", - unique_id="00:00:00:00:00:00_1_65792", - supported_features=0, - capabilities={"supported_color_modes": ["hs"]}, - state="off", - ), - EntityTestInfo( - "number.aqara_hub_1563_volume", - friendly_name="Aqara Hub-1563 Volume", - unique_id="00:00:00:00:00:00_1_65536_65541", - capabilities={ - "max": 100, - "min": 0, - "mode": NumberMode.AUTO, - "step": 1, - }, - entity_category=EntityCategory.CONFIG, - state="40", - ), - EntityTestInfo( - "switch.aqara_hub_1563_pairing_mode", - friendly_name="Aqara Hub-1563 Pairing Mode", - unique_id="00:00:00:00:00:00_1_65536_65538", - entity_category=EntityCategory.CONFIG, - state="off", - ), - ], - ), - ) - - -async def test_aqara_gateway_e1_setup(hass: HomeAssistant) -> None: - """Test that an Aqara E1 Gateway can be correctly setup in HA.""" - accessories = await setup_accessories_from_file(hass, "aqara_e1.json") - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="Aqara-Hub-E1-00A0", - model="HE1-G01", - manufacturer="Aqara", - sw_version="3.3.0", - hw_version="1.0", - serial_number="00aa00000a0", - devices=[], - entities=[ - EntityTestInfo( - "alarm_control_panel.aqara_hub_e1_00a0_security_system", - friendly_name="Aqara-Hub-E1-00A0 Security System", - unique_id="00:00:00:00:00:00_1_16", - supported_features=AlarmControlPanelEntityFeature.ARM_NIGHT - | AlarmControlPanelEntityFeature.ARM_HOME - | AlarmControlPanelEntityFeature.ARM_AWAY, - state="disarmed", - ), - EntityTestInfo( - "number.aqara_hub_e1_00a0_volume", - friendly_name="Aqara-Hub-E1-00A0 Volume", - unique_id="00:00:00:00:00:00_1_17_1114116", - capabilities={ - "max": 100, - "min": 0, - "mode": NumberMode.AUTO, - "step": 1, - }, - entity_category=EntityCategory.CONFIG, - state="40", - ), - EntityTestInfo( - "switch.aqara_hub_e1_00a0_pairing_mode", - friendly_name="Aqara-Hub-E1-00A0 Pairing Mode", - unique_id="00:00:00:00:00:00_1_17_1114117", - entity_category=EntityCategory.CONFIG, - state="off", - ), - ], - ), - ) diff --git a/tests/components/homekit_controller/specific_devices/test_arlo_baby.py b/tests/components/homekit_controller/specific_devices/test_arlo_baby.py deleted file mode 100644 index ae44f7f774f..00000000000 --- a/tests/components/homekit_controller/specific_devices/test_arlo_baby.py +++ /dev/null @@ -1,87 +0,0 @@ -"""Make sure that an Arlo Baby can be setup.""" -from homeassistant.components.sensor import SensorStateClass -from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTemperature -from homeassistant.core import HomeAssistant - -from ..common import ( - HUB_TEST_ACCESSORY_ID, - DeviceTestInfo, - EntityTestInfo, - assert_devices_and_entities_created, - setup_accessories_from_file, - setup_test_accessories, -) - - -async def test_arlo_baby_setup(hass: HomeAssistant) -> None: - """Test that an Arlo Baby can be correctly setup in HA.""" - accessories = await setup_accessories_from_file(hass, "arlo_baby.json") - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="ArloBabyA0", - model="ABC1000", - manufacturer="Netgear, Inc", - sw_version="1.10.931", - hw_version="", - serial_number="00A0000000000", - devices=[], - entities=[ - EntityTestInfo( - entity_id="camera.arlobabya0", - unique_id="00:00:00:00:00:00_1", - friendly_name="ArloBabyA0", - state="idle", - ), - EntityTestInfo( - entity_id="binary_sensor.arlobabya0_motion", - unique_id="00:00:00:00:00:00_1_500", - friendly_name="ArloBabyA0 Motion", - state="off", - ), - EntityTestInfo( - entity_id="sensor.arlobabya0_battery", - unique_id="00:00:00:00:00:00_1_700", - friendly_name="ArloBabyA0 Battery", - entity_category=EntityCategory.DIAGNOSTIC, - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unit_of_measurement=PERCENTAGE, - state="82", - ), - EntityTestInfo( - entity_id="sensor.arlobabya0_humidity", - unique_id="00:00:00:00:00:00_1_900", - friendly_name="ArloBabyA0 Humidity", - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unit_of_measurement=PERCENTAGE, - state="60.099998", - ), - EntityTestInfo( - entity_id="sensor.arlobabya0_temperature", - unique_id="00:00:00:00:00:00_1_1000", - friendly_name="ArloBabyA0 Temperature", - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unit_of_measurement=UnitOfTemperature.CELSIUS, - state="24.0", - ), - EntityTestInfo( - entity_id="sensor.arlobabya0_air_quality", - unique_id="00:00:00:00:00:00_1_800_802", - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - friendly_name="ArloBabyA0 Air Quality", - state="1", - ), - EntityTestInfo( - entity_id="light.arlobabya0_nightlight", - unique_id="00:00:00:00:00:00_1_1100", - friendly_name="ArloBabyA0 Nightlight", - supported_features=0, - capabilities={"supported_color_modes": ["hs"]}, - state="off", - ), - ], - ), - ) diff --git a/tests/components/homekit_controller/specific_devices/test_ecobee_501.py b/tests/components/homekit_controller/specific_devices/test_ecobee_501.py deleted file mode 100644 index c833ea71116..00000000000 --- a/tests/components/homekit_controller/specific_devices/test_ecobee_501.py +++ /dev/null @@ -1,67 +0,0 @@ -"""Tests for Ecobee 501.""" -from homeassistant.components.climate import ( - SUPPORT_FAN_MODE, - SUPPORT_TARGET_HUMIDITY, - SUPPORT_TARGET_TEMPERATURE, - SUPPORT_TARGET_TEMPERATURE_RANGE, -) -from homeassistant.const import STATE_ON -from homeassistant.core import HomeAssistant - -from ..common import ( - HUB_TEST_ACCESSORY_ID, - DeviceTestInfo, - EntityTestInfo, - assert_devices_and_entities_created, - setup_accessories_from_file, - setup_test_accessories, -) - - -async def test_ecobee501_setup(hass: HomeAssistant) -> None: - """Test that a Ecobee 501 can be correctly setup in HA.""" - accessories = await setup_accessories_from_file(hass, "ecobee_501.json") - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="My ecobee", - model="ECB501", - manufacturer="ecobee Inc.", - sw_version="4.7.340214", - hw_version="", - serial_number="123456789016", - devices=[], - entities=[ - EntityTestInfo( - entity_id="climate.my_ecobee", - friendly_name="My ecobee", - unique_id="00:00:00:00:00:00_1_16", - supported_features=( - SUPPORT_TARGET_TEMPERATURE - | SUPPORT_TARGET_TEMPERATURE_RANGE - | SUPPORT_TARGET_HUMIDITY - | SUPPORT_FAN_MODE - ), - capabilities={ - "hvac_modes": ["off", "heat", "cool", "heat_cool"], - "fan_modes": ["on", "auto"], - "min_temp": 7.2, - "max_temp": 33.3, - "min_humidity": 20, - "max_humidity": 50, - }, - state="heat_cool", - ), - EntityTestInfo( - entity_id="binary_sensor.my_ecobee_occupancy", - friendly_name="My ecobee Occupancy", - unique_id="00:00:00:00:00:00_1_57", - unit_of_measurement=None, - state=STATE_ON, - ), - ], - ), - ) diff --git a/tests/components/homekit_controller/specific_devices/test_ecobee_occupancy.py b/tests/components/homekit_controller/specific_devices/test_ecobee_occupancy.py deleted file mode 100644 index f9d19c5f9c1..00000000000 --- a/tests/components/homekit_controller/specific_devices/test_ecobee_occupancy.py +++ /dev/null @@ -1,42 +0,0 @@ -"""Regression tests for Ecobee occupancy. - -https://github.com/home-assistant/core/issues/31827 -""" -from homeassistant.core import HomeAssistant - -from ..common import ( - HUB_TEST_ACCESSORY_ID, - DeviceTestInfo, - EntityTestInfo, - assert_devices_and_entities_created, - setup_accessories_from_file, - setup_test_accessories, -) - - -async def test_ecobee_occupancy_setup(hass: HomeAssistant) -> None: - """Test that an Ecbobee occupancy sensor be correctly setup in HA.""" - accessories = await setup_accessories_from_file(hass, "ecobee_occupancy.json") - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="Master Fan", - model="ecobee Switch+", - manufacturer="ecobee Inc.", - sw_version="4.5.130201", - hw_version="", - serial_number="111111111111", - devices=[], - entities=[ - EntityTestInfo( - entity_id="binary_sensor.master_fan", - friendly_name="Master Fan", - unique_id="00:00:00:00:00:00_1_56", - state="off", - ), - ], - ), - ) diff --git a/tests/components/homekit_controller/specific_devices/test_eve_degree.py b/tests/components/homekit_controller/specific_devices/test_eve_degree.py deleted file mode 100644 index 10fcd8ede8e..00000000000 --- a/tests/components/homekit_controller/specific_devices/test_eve_degree.py +++ /dev/null @@ -1,87 +0,0 @@ -"""Make sure that Eve Degree (via Eve Extend) is enumerated properly.""" -from homeassistant.components.number import NumberMode -from homeassistant.components.sensor import SensorStateClass -from homeassistant.const import ( - PERCENTAGE, - EntityCategory, - UnitOfPressure, - UnitOfTemperature, -) -from homeassistant.core import HomeAssistant - -from ..common import ( - HUB_TEST_ACCESSORY_ID, - DeviceTestInfo, - EntityTestInfo, - assert_devices_and_entities_created, - setup_accessories_from_file, - setup_test_accessories, -) - - -async def test_eve_degree_setup(hass: HomeAssistant) -> None: - """Test that the accessory can be correctly setup in HA.""" - accessories = await setup_accessories_from_file(hass, "eve_degree.json") - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="Eve Degree AA11", - model="Eve Degree 00AAA0000", - manufacturer="Elgato", - sw_version="1.2.8", - hw_version="1.0.0", - serial_number="AA00A0A00000", - devices=[], - entities=[ - EntityTestInfo( - entity_id="sensor.eve_degree_aa11_temperature", - unique_id="00:00:00:00:00:00_1_22", - friendly_name="Eve Degree AA11 Temperature", - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unit_of_measurement=UnitOfTemperature.CELSIUS, - state="22.7719116210938", - ), - EntityTestInfo( - entity_id="sensor.eve_degree_aa11_humidity", - unique_id="00:00:00:00:00:00_1_27", - friendly_name="Eve Degree AA11 Humidity", - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unit_of_measurement=PERCENTAGE, - state="59.4818115234375", - ), - EntityTestInfo( - entity_id="sensor.eve_degree_aa11_air_pressure", - unique_id="00:00:00:00:00:00_1_30_32", - friendly_name="Eve Degree AA11 Air Pressure", - unit_of_measurement=UnitOfPressure.HPA, - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - state="1005.70001220703", - ), - EntityTestInfo( - entity_id="sensor.eve_degree_aa11_battery", - unique_id="00:00:00:00:00:00_1_17", - friendly_name="Eve Degree AA11 Battery", - entity_category=EntityCategory.DIAGNOSTIC, - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unit_of_measurement=PERCENTAGE, - state="65", - ), - EntityTestInfo( - entity_id="number.eve_degree_aa11_elevation", - unique_id="00:00:00:00:00:00_1_30_33", - friendly_name="Eve Degree AA11 Elevation", - capabilities={ - "max": 9000, - "min": -450, - "mode": NumberMode.AUTO, - "step": 1, - }, - state="0", - entity_category=EntityCategory.CONFIG, - ), - ], - ), - ) diff --git a/tests/components/homekit_controller/specific_devices/test_eve_energy.py b/tests/components/homekit_controller/specific_devices/test_eve_energy.py deleted file mode 100644 index 5f8415c5074..00000000000 --- a/tests/components/homekit_controller/specific_devices/test_eve_energy.py +++ /dev/null @@ -1,93 +0,0 @@ -"""Make sure that Eve Degree (via Eve Extend) is enumerated properly.""" -from homeassistant.components.sensor import SensorStateClass -from homeassistant.const import ( - ELECTRIC_CURRENT_AMPERE, - ELECTRIC_POTENTIAL_VOLT, - ENERGY_KILO_WATT_HOUR, - POWER_WATT, - EntityCategory, -) -from homeassistant.core import HomeAssistant - -from ..common import ( - HUB_TEST_ACCESSORY_ID, - DeviceTestInfo, - EntityTestInfo, - assert_devices_and_entities_created, - setup_accessories_from_file, - setup_test_accessories, -) - - -async def test_eve_energy_setup(hass: HomeAssistant) -> None: - """Test that the accessory can be correctly setup in HA.""" - accessories = await setup_accessories_from_file(hass, "eve_energy.json") - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="Eve Energy 50FF", - model="Eve Energy 20EAO8601", - manufacturer="Elgato", - sw_version="1.2.9", - hw_version="1.0.0", - serial_number="AA00A0A00000", - devices=[], - entities=[ - EntityTestInfo( - entity_id="switch.eve_energy_50ff", - unique_id="00:00:00:00:00:00_1_28", - friendly_name="Eve Energy 50FF", - state="off", - ), - EntityTestInfo( - entity_id="sensor.eve_energy_50ff_amps", - unique_id="00:00:00:00:00:00_1_28_33", - friendly_name="Eve Energy 50FF Amps", - unit_of_measurement=ELECTRIC_CURRENT_AMPERE, - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - state="0", - ), - EntityTestInfo( - entity_id="sensor.eve_energy_50ff_volts", - unique_id="00:00:00:00:00:00_1_28_32", - friendly_name="Eve Energy 50FF Volts", - unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - state="0.400000005960464", - ), - EntityTestInfo( - entity_id="sensor.eve_energy_50ff_power", - unique_id="00:00:00:00:00:00_1_28_34", - friendly_name="Eve Energy 50FF Power", - unit_of_measurement=POWER_WATT, - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - state="0", - ), - EntityTestInfo( - entity_id="sensor.eve_energy_50ff_energy_kwh", - unique_id="00:00:00:00:00:00_1_28_35", - friendly_name="Eve Energy 50FF Energy kWh", - capabilities={"state_class": SensorStateClass.TOTAL_INCREASING}, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, - state="0.28999999165535", - ), - EntityTestInfo( - entity_id="switch.eve_energy_50ff_lock_physical_controls", - unique_id="00:00:00:00:00:00_1_28_36", - friendly_name="Eve Energy 50FF Lock Physical Controls", - entity_category=EntityCategory.CONFIG, - state="off", - ), - EntityTestInfo( - entity_id="button.eve_energy_50ff_identify", - unique_id="00:00:00:00:00:00_1_1_3", - friendly_name="Eve Energy 50FF Identify", - entity_category=EntityCategory.DIAGNOSTIC, - state="unknown", - ), - ], - ), - ) diff --git a/tests/components/homekit_controller/specific_devices/test_haa_fan.py b/tests/components/homekit_controller/specific_devices/test_haa_fan.py deleted file mode 100644 index 07a7324032b..00000000000 --- a/tests/components/homekit_controller/specific_devices/test_haa_fan.py +++ /dev/null @@ -1,82 +0,0 @@ -"""Make sure that a H.A.A. fan can be setup.""" -from homeassistant.components.fan import ATTR_PERCENTAGE, FanEntityFeature -from homeassistant.const import EntityCategory -from homeassistant.core import HomeAssistant - -from ..common import ( - HUB_TEST_ACCESSORY_ID, - DeviceTestInfo, - EntityTestInfo, - assert_devices_and_entities_created, - setup_accessories_from_file, - setup_test_accessories, -) - - -async def test_haa_fan_setup(hass: HomeAssistant) -> None: - """Test that a H.A.A. fan can be correctly setup in HA.""" - accessories = await setup_accessories_from_file(hass, "haa_fan.json") - await setup_test_accessories(hass, accessories) - - haa_fan_state = hass.states.get("fan.haa_c718b3") - attributes = haa_fan_state.attributes - assert attributes[ATTR_PERCENTAGE] == 66 - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="HAA-C718B3", - model="RavenSystem HAA", - manufacturer="José A. Jiménez Campos", - sw_version="5.0.18", - hw_version="", - serial_number="C718B3-1", - devices=[ - DeviceTestInfo( - name="HAA-C718B3", - model="RavenSystem HAA", - manufacturer="José A. Jiménez Campos", - sw_version="5.0.18", - hw_version="", - serial_number="C718B3-2", - unique_id="00:00:00:00:00:00:aid:2", - devices=[], - entities=[ - EntityTestInfo( - entity_id="switch.haa_c718b3", - friendly_name="HAA-C718B3", - unique_id="00:00:00:00:00:00_2_8", - state="off", - ) - ], - ), - ], - entities=[ - EntityTestInfo( - entity_id="fan.haa_c718b3", - friendly_name="HAA-C718B3", - unique_id="00:00:00:00:00:00_1_8", - state="on", - supported_features=FanEntityFeature.SET_SPEED, - capabilities={ - "preset_modes": None, - }, - ), - EntityTestInfo( - entity_id="button.haa_c718b3_setup", - friendly_name="HAA-C718B3 Setup", - unique_id="00:00:00:00:00:00_1_1010_1012", - entity_category=EntityCategory.CONFIG, - state="unknown", - ), - EntityTestInfo( - entity_id="button.haa_c718b3_update", - friendly_name="HAA-C718B3 Update", - unique_id="00:00:00:00:00:00_1_1010_1011", - entity_category=EntityCategory.CONFIG, - state="unknown", - ), - ], - ), - ) diff --git a/tests/components/homekit_controller/specific_devices/test_homeassistant_bridge.py b/tests/components/homekit_controller/specific_devices/test_homeassistant_bridge.py deleted file mode 100644 index 84a14a8488d..00000000000 --- a/tests/components/homekit_controller/specific_devices/test_homeassistant_bridge.py +++ /dev/null @@ -1,62 +0,0 @@ -"""Test against characteristics captured from the Home Assistant HomeKit bridge running demo platforms.""" -from homeassistant.components.fan import FanEntityFeature -from homeassistant.core import HomeAssistant - -from ..common import ( - HUB_TEST_ACCESSORY_ID, - DeviceTestInfo, - EntityTestInfo, - assert_devices_and_entities_created, - setup_accessories_from_file, - setup_test_accessories, -) - - -async def test_homeassistant_bridge_fan_setup(hass: HomeAssistant) -> None: - """Test that a SIMPLEconnect fan can be correctly setup in HA.""" - accessories = await setup_accessories_from_file( - hass, "home_assistant_bridge_fan.json" - ) - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="Home Assistant Bridge", - model="Bridge", - manufacturer="Home Assistant", - sw_version="0.104.0.dev0", - hw_version="", - serial_number="homekit.bridge", - devices=[ - DeviceTestInfo( - name="Living Room Fan", - model="Fan", - manufacturer="Home Assistant", - sw_version="0.104.0.dev0", - hw_version="", - serial_number="fan.living_room_fan", - unique_id="00:00:00:00:00:00:aid:1256851357", - devices=[], - entities=[ - EntityTestInfo( - entity_id="fan.living_room_fan", - friendly_name="Living Room Fan", - unique_id="00:00:00:00:00:00_1256851357_8", - supported_features=( - FanEntityFeature.DIRECTION - | FanEntityFeature.SET_SPEED - | FanEntityFeature.OSCILLATE - ), - capabilities={ - "preset_modes": None, - }, - state="off", - ) - ], - ), - ], - entities=[], - ), - ) diff --git a/tests/components/homekit_controller/specific_devices/test_homespan_daikin_bridge.py b/tests/components/homekit_controller/specific_devices/test_homespan_daikin_bridge.py deleted file mode 100644 index 5bb7003e58b..00000000000 --- a/tests/components/homekit_controller/specific_devices/test_homespan_daikin_bridge.py +++ /dev/null @@ -1,51 +0,0 @@ -"""Tests for handling accessories on a Homespan esp32 daikin bridge.""" -from homeassistant.components.climate import ClimateEntityFeature -from homeassistant.core import HomeAssistant - -from ..common import ( - HUB_TEST_ACCESSORY_ID, - DeviceTestInfo, - EntityTestInfo, - assert_devices_and_entities_created, - setup_accessories_from_file, - setup_test_accessories, -) - - -async def test_homespan_daikin_bridge_setup(hass: HomeAssistant) -> None: - """Test that aHomespan esp32 daikin bridge can be correctly setup in HA via HomeKit.""" - accessories = await setup_accessories_from_file(hass, "homespan_daikin_bridge.json") - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="Air Conditioner", - model="Daikin-fwec3a-esp32-homekit-bridge", - manufacturer="Garzola Marco", - sw_version="1.0.0", - hw_version="1.0.0", - serial_number="00000001", - devices=[], - entities=[ - EntityTestInfo( - entity_id="climate.air_conditioner_slaveid_1", - friendly_name="Air Conditioner SlaveID 1", - unique_id="00:00:00:00:00:00_1_9", - supported_features=( - ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.FAN_MODE - ), - capabilities={ - "hvac_modes": ["heat_cool", "heat", "cool", "off"], - "min_temp": 18, - "max_temp": 32, - "target_temp_step": 0.5, - "fan_modes": ["off", "low", "medium", "high"], - }, - state="cool", - ), - ], - ), - ) diff --git a/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py b/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py index ca2392be4ce..e25d5b7830e 100644 --- a/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py +++ b/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py @@ -7,62 +7,16 @@ from aiohomekit.model import CharacteristicsTypes, ServicesTypes from aiohomekit.testing import FakePairing import pytest -from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant import homeassistant.util.dt as dt_util -from ..common import ( - HUB_TEST_ACCESSORY_ID, - DeviceTestInfo, - EntityTestInfo, - Helper, - assert_devices_and_entities_created, - setup_accessories_from_file, - setup_test_accessories, -) +from ..common import Helper, setup_accessories_from_file, setup_test_accessories from tests.common import async_fire_time_changed LIGHT_ON = ("lightbulb", "on") -async def test_koogeek_ls1_setup(hass: HomeAssistant) -> None: - """Test that a Koogeek LS1 can be correctly setup in HA.""" - accessories = await setup_accessories_from_file(hass, "koogeek_ls1.json") - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="Koogeek-LS1-20833F", - model="LS1", - manufacturer="Koogeek", - sw_version="2.2.15", - hw_version="", - serial_number="AAAA011111111111", - devices=[], - entities=[ - EntityTestInfo( - entity_id="light.koogeek_ls1_20833f_light_strip", - friendly_name="Koogeek-LS1-20833F Light Strip", - unique_id="00:00:00:00:00:00_1_7", - supported_features=0, - capabilities={"supported_color_modes": ["hs"]}, - state="off", - ), - EntityTestInfo( - entity_id="button.koogeek_ls1_20833f_identify", - friendly_name="Koogeek-LS1-20833F Identify", - unique_id="00:00:00:00:00:00_1_1_6", - entity_category=EntityCategory.DIAGNOSTIC, - state="unknown", - ), - ], - ), - ) - - @pytest.mark.parametrize("failure_cls", [AccessoryDisconnectedError, EncryptionError]) async def test_recover_from_failure(hass: HomeAssistant, utcnow, failure_cls) -> None: """Test that entity actually recovers from a network connection drop. diff --git a/tests/components/homekit_controller/specific_devices/test_koogeek_p1eu.py b/tests/components/homekit_controller/specific_devices/test_koogeek_p1eu.py deleted file mode 100644 index 91506382a8a..00000000000 --- a/tests/components/homekit_controller/specific_devices/test_koogeek_p1eu.py +++ /dev/null @@ -1,49 +0,0 @@ -"""Make sure that existing Koogeek P1EU support isn't broken.""" -from homeassistant.components.sensor import SensorStateClass -from homeassistant.const import POWER_WATT -from homeassistant.core import HomeAssistant - -from ..common import ( - HUB_TEST_ACCESSORY_ID, - DeviceTestInfo, - EntityTestInfo, - assert_devices_and_entities_created, - setup_accessories_from_file, - setup_test_accessories, -) - - -async def test_koogeek_p1eu_setup(hass: HomeAssistant) -> None: - """Test that a Koogeek P1EU can be correctly setup in HA.""" - accessories = await setup_accessories_from_file(hass, "koogeek_p1eu.json") - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="Koogeek-P1-A00AA0", - model="P1EU", - manufacturer="Koogeek", - sw_version="2.3.7", - hw_version="", - serial_number="EUCP03190xxxxx48", - devices=[], - entities=[ - EntityTestInfo( - entity_id="switch.koogeek_p1_a00aa0_outlet", - friendly_name="Koogeek-P1-A00AA0 outlet", - unique_id="00:00:00:00:00:00_1_7", - state="off", - ), - EntityTestInfo( - entity_id="sensor.koogeek_p1_a00aa0_power", - friendly_name="Koogeek-P1-A00AA0 Power", - unique_id="00:00:00:00:00:00_1_21_22", - unit_of_measurement=POWER_WATT, - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - state="5", - ), - ], - ), - ) diff --git a/tests/components/homekit_controller/specific_devices/test_lennox_e30.py b/tests/components/homekit_controller/specific_devices/test_lennox_e30.py deleted file mode 100644 index 4578014f009..00000000000 --- a/tests/components/homekit_controller/specific_devices/test_lennox_e30.py +++ /dev/null @@ -1,54 +0,0 @@ -"""Regression tests for Aqara Gateway V3. - -https://github.com/home-assistant/core/issues/20885 -""" -from homeassistant.components.climate import ( - SUPPORT_TARGET_TEMPERATURE, - SUPPORT_TARGET_TEMPERATURE_RANGE, -) -from homeassistant.core import HomeAssistant - -from ..common import ( - HUB_TEST_ACCESSORY_ID, - DeviceTestInfo, - EntityTestInfo, - assert_devices_and_entities_created, - setup_accessories_from_file, - setup_test_accessories, -) - - -async def test_lennox_e30_setup(hass: HomeAssistant) -> None: - """Test that a Lennox E30 can be correctly setup in HA.""" - accessories = await setup_accessories_from_file(hass, "lennox_e30.json") - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="Lennox", - model="E30 2B", - manufacturer="Lennox", - sw_version="3.40.XX", - hw_version="3.0.XX", - serial_number="XXXXXXXX", - devices=[], - entities=[ - EntityTestInfo( - entity_id="climate.lennox", - friendly_name="Lennox", - unique_id="00:00:00:00:00:00_1_100", - supported_features=( - SUPPORT_TARGET_TEMPERATURE | SUPPORT_TARGET_TEMPERATURE_RANGE - ), - capabilities={ - "hvac_modes": ["off", "heat", "cool", "heat_cool"], - "max_temp": 37, - "min_temp": 4.5, - }, - state="heat_cool", - ), - ], - ), - ) diff --git a/tests/components/homekit_controller/specific_devices/test_lg_tv.py b/tests/components/homekit_controller/specific_devices/test_lg_tv.py deleted file mode 100644 index f35e7da2bdd..00000000000 --- a/tests/components/homekit_controller/specific_devices/test_lg_tv.py +++ /dev/null @@ -1,63 +0,0 @@ -"""Make sure that handling real world LG HomeKit characteristics isn't broken.""" -from homeassistant.components.media_player import MediaPlayerEntityFeature -from homeassistant.core import HomeAssistant - -from ..common import ( - HUB_TEST_ACCESSORY_ID, - DeviceTestInfo, - EntityTestInfo, - assert_devices_and_entities_created, - setup_accessories_from_file, - setup_test_accessories, -) - - -async def test_lg_tv(hass: HomeAssistant) -> None: - """Test that a Koogeek LS1 can be correctly setup in HA.""" - accessories = await setup_accessories_from_file(hass, "lg_tv.json") - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="LG webOS TV AF80", - model="OLED55B9PUA", - manufacturer="LG Electronics", - sw_version="04.71.04", - hw_version="1", - serial_number="999AAAAAA999", - devices=[], - entities=[ - EntityTestInfo( - entity_id="media_player.lg_webos_tv_af80", - friendly_name="LG webOS TV AF80", - unique_id="00:00:00:00:00:00_1_48", - supported_features=( - MediaPlayerEntityFeature.PAUSE - | MediaPlayerEntityFeature.PLAY - | MediaPlayerEntityFeature.SELECT_SOURCE - ), - capabilities={ - "source_list": [ - "AirPlay", - "Live TV", - "HDMI 1", - "Sony", - "Apple", - "AV", - "HDMI 4", - ] - }, - # The LG TV doesn't (at least at this patch level) report - # its media state via CURRENT_MEDIA_STATE. Therefore "ok" - # is the best we can say. - state="on", - ), - ], - ), - ) - - """ - assert state.attributes["source"] == "HDMI 4" - """ diff --git a/tests/components/homekit_controller/specific_devices/test_lutron_caseta_bridge.py b/tests/components/homekit_controller/specific_devices/test_lutron_caseta_bridge.py deleted file mode 100644 index 9cb65907e8a..00000000000 --- a/tests/components/homekit_controller/specific_devices/test_lutron_caseta_bridge.py +++ /dev/null @@ -1,55 +0,0 @@ -"""Tests for handling accessories on a Lutron Caseta bridge via HomeKit.""" -from homeassistant.const import STATE_OFF -from homeassistant.core import HomeAssistant - -from ..common import ( - HUB_TEST_ACCESSORY_ID, - DeviceTestInfo, - EntityTestInfo, - assert_devices_and_entities_created, - setup_accessories_from_file, - setup_test_accessories, -) - - -async def test_lutron_caseta_bridge_setup(hass: HomeAssistant) -> None: - """Test that a Lutron Caseta bridge can be correctly setup in HA via HomeKit.""" - accessories = await setup_accessories_from_file(hass, "lutron_caseta_bridge.json") - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="Smart Bridge 2", - model="L-BDG2-WH", - manufacturer="Lutron Electronics Co., Inc", - sw_version="08.08", - hw_version="", - serial_number="12344331", - devices=[ - DeviceTestInfo( - name="Cas\u00e9ta\u00ae Wireless Fan Speed Control", - model="PD-FSQN-XX", - manufacturer="Lutron Electronics Co., Inc", - sw_version="001.005", - hw_version="", - serial_number="39024290", - unique_id="00:00:00:00:00:00:aid:21474836482", - devices=[], - entities=[ - EntityTestInfo( - entity_id="fan.caseta_r_wireless_fan_speed_control", - friendly_name="Caséta® Wireless Fan Speed Control", - unique_id="00:00:00:00:00:00_21474836482_2", - unit_of_measurement=None, - supported_features=1, - state=STATE_OFF, - capabilities={"preset_modes": None}, - ) - ], - ), - ], - entities=[], - ), - ) diff --git a/tests/components/homekit_controller/specific_devices/test_mss425f.py b/tests/components/homekit_controller/specific_devices/test_mss425f.py deleted file mode 100644 index 1ab608e3d2e..00000000000 --- a/tests/components/homekit_controller/specific_devices/test_mss425f.py +++ /dev/null @@ -1,71 +0,0 @@ -"""Tests for the Meross MSS425f power strip.""" -from homeassistant.const import STATE_ON, STATE_UNKNOWN, EntityCategory -from homeassistant.core import HomeAssistant - -from ..common import ( - HUB_TEST_ACCESSORY_ID, - DeviceTestInfo, - EntityTestInfo, - assert_devices_and_entities_created, - setup_accessories_from_file, - setup_test_accessories, -) - - -async def test_meross_mss425f_setup(hass: HomeAssistant) -> None: - """Test that a MSS425f can be correctly setup in HA.""" - accessories = await setup_accessories_from_file(hass, "mss425f.json") - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="MSS425F-15cc", - model="MSS425F", - manufacturer="Meross", - sw_version="4.2.3", - hw_version="4.0.0", - serial_number="HH41234", - devices=[], - entities=[ - EntityTestInfo( - entity_id="button.mss425f_15cc_identify", - friendly_name="MSS425F-15cc Identify", - unique_id="00:00:00:00:00:00_1_1_2", - entity_category=EntityCategory.DIAGNOSTIC, - state=STATE_UNKNOWN, - ), - EntityTestInfo( - entity_id="switch.mss425f_15cc_outlet_1", - friendly_name="MSS425F-15cc Outlet-1", - unique_id="00:00:00:00:00:00_1_12", - state=STATE_ON, - ), - EntityTestInfo( - entity_id="switch.mss425f_15cc_outlet_2", - friendly_name="MSS425F-15cc Outlet-2", - unique_id="00:00:00:00:00:00_1_15", - state=STATE_ON, - ), - EntityTestInfo( - entity_id="switch.mss425f_15cc_outlet_3", - friendly_name="MSS425F-15cc Outlet-3", - unique_id="00:00:00:00:00:00_1_18", - state=STATE_ON, - ), - EntityTestInfo( - entity_id="switch.mss425f_15cc_outlet_4", - friendly_name="MSS425F-15cc Outlet-4", - unique_id="00:00:00:00:00:00_1_21", - state=STATE_ON, - ), - EntityTestInfo( - entity_id="switch.mss425f_15cc_usb", - friendly_name="MSS425F-15cc USB", - unique_id="00:00:00:00:00:00_1_24", - state=STATE_ON, - ), - ], - ), - ) diff --git a/tests/components/homekit_controller/specific_devices/test_mss565.py b/tests/components/homekit_controller/specific_devices/test_mss565.py deleted file mode 100644 index 78d8d8f5250..00000000000 --- a/tests/components/homekit_controller/specific_devices/test_mss565.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Tests for the Meross MSS565 wall switch.""" -from homeassistant.const import STATE_ON -from homeassistant.core import HomeAssistant - -from ..common import ( - HUB_TEST_ACCESSORY_ID, - DeviceTestInfo, - EntityTestInfo, - assert_devices_and_entities_created, - setup_accessories_from_file, - setup_test_accessories, -) - - -async def test_meross_mss565_setup(hass: HomeAssistant) -> None: - """Test that a MSS565 can be correctly setup in HA.""" - accessories = await setup_accessories_from_file(hass, "mss565.json") - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="MSS565-28da", - model="MSS565", - manufacturer="Meross", - sw_version="4.1.9", - hw_version="4.0.0", - serial_number="BB1121", - devices=[], - entities=[ - EntityTestInfo( - entity_id="light.mss565_28da_dimmer_switch", - friendly_name="MSS565-28da Dimmer Switch", - unique_id="00:00:00:00:00:00_1_12", - capabilities={"supported_color_modes": ["brightness"]}, - state=STATE_ON, - ), - ], - ), - ) diff --git a/tests/components/homekit_controller/specific_devices/test_mysa_living.py b/tests/components/homekit_controller/specific_devices/test_mysa_living.py deleted file mode 100644 index 48828a2a6ad..00000000000 --- a/tests/components/homekit_controller/specific_devices/test_mysa_living.py +++ /dev/null @@ -1,72 +0,0 @@ -"""Make sure that Mysa Living is enumerated properly.""" -from homeassistant.components.climate import ClimateEntityFeature -from homeassistant.components.sensor import SensorStateClass -from homeassistant.const import PERCENTAGE, UnitOfTemperature -from homeassistant.core import HomeAssistant - -from ..common import ( - HUB_TEST_ACCESSORY_ID, - DeviceTestInfo, - EntityTestInfo, - assert_devices_and_entities_created, - setup_accessories_from_file, - setup_test_accessories, -) - - -async def test_mysa_living_setup(hass: HomeAssistant) -> None: - """Test that the accessory can be correctly setup in HA.""" - accessories = await setup_accessories_from_file(hass, "mysa_living.json") - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="Mysa-85dda9", - model="v1", - manufacturer="Empowered Homes Inc.", - sw_version="2.8.1", - hw_version="", - serial_number="AAAAAAA000", - devices=[], - entities=[ - EntityTestInfo( - entity_id="climate.mysa_85dda9_thermostat", - friendly_name="Mysa-85dda9 Thermostat", - unique_id="00:00:00:00:00:00_1_20", - supported_features=ClimateEntityFeature.TARGET_TEMPERATURE, - capabilities={ - "hvac_modes": ["off", "heat", "cool", "heat_cool"], - "max_temp": 35, - "min_temp": 7, - }, - state="off", - ), - EntityTestInfo( - entity_id="sensor.mysa_85dda9_current_humidity", - friendly_name="Mysa-85dda9 Current Humidity", - unique_id="00:00:00:00:00:00_1_20_27", - unit_of_measurement=PERCENTAGE, - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - state="40", - ), - EntityTestInfo( - entity_id="sensor.mysa_85dda9_current_temperature", - friendly_name="Mysa-85dda9 Current Temperature", - unique_id="00:00:00:00:00:00_1_20_25", - unit_of_measurement=UnitOfTemperature.CELSIUS, - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - state="24.1", - ), - EntityTestInfo( - entity_id="light.mysa_85dda9_display", - friendly_name="Mysa-85dda9 Display", - unique_id="00:00:00:00:00:00_1_40", - supported_features=0, - capabilities={"supported_color_modes": ["brightness"]}, - state="off", - ), - ], - ), - ) diff --git a/tests/components/homekit_controller/specific_devices/test_nanoleaf_strip_nl55.py b/tests/components/homekit_controller/specific_devices/test_nanoleaf_strip_nl55.py deleted file mode 100644 index 629059935cf..00000000000 --- a/tests/components/homekit_controller/specific_devices/test_nanoleaf_strip_nl55.py +++ /dev/null @@ -1,92 +0,0 @@ -"""Make sure that Nanoleaf NL55 works with BLE.""" -from homeassistant.const import EntityCategory -from homeassistant.core import HomeAssistant - -from ..common import ( - HUB_TEST_ACCESSORY_ID, - DeviceTestInfo, - EntityTestInfo, - assert_devices_and_entities_created, - setup_accessories_from_file, - setup_test_accessories, -) - -LIGHT_ON = ("lightbulb", "on") - - -async def test_nanoleaf_nl55_setup(hass: HomeAssistant) -> None: - """Test that a Nanoleaf NL55 can be correctly setup in HA.""" - accessories = await setup_accessories_from_file(hass, "nanoleaf_strip_nl55.json") - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="Nanoleaf Strip 3B32", - model="NL55", - manufacturer="Nanoleaf", - sw_version="1.4.40", - hw_version="1.2.4", - serial_number="AAAA011111111111", - devices=[], - entities=[ - EntityTestInfo( - entity_id="light.nanoleaf_strip_3b32_nanoleaf_light_strip", - friendly_name="Nanoleaf Strip 3B32 Nanoleaf Light Strip", - unique_id="00:00:00:00:00:00_1_19", - supported_features=0, - capabilities={ - "max_color_temp_kelvin": 6535, - "min_color_temp_kelvin": 2127, - "max_mireds": 470, - "min_mireds": 153, - "supported_color_modes": ["color_temp", "hs"], - }, - state="on", - ), - EntityTestInfo( - entity_id="button.nanoleaf_strip_3b32_identify", - friendly_name="Nanoleaf Strip 3B32 Identify", - unique_id="00:00:00:00:00:00_1_1_2", - entity_category=EntityCategory.DIAGNOSTIC, - state="unknown", - ), - EntityTestInfo( - entity_id="sensor.nanoleaf_strip_3b32_thread_capabilities", - friendly_name="Nanoleaf Strip 3B32 Thread Capabilities", - unique_id="00:00:00:00:00:00_1_31_115", - entity_category=EntityCategory.DIAGNOSTIC, - capabilities={ - "options": [ - "border_router_capable", - "full", - "minimal", - "none", - "router_eligible", - "sleepy", - ] - }, - state="border_router_capable", - ), - EntityTestInfo( - entity_id="sensor.nanoleaf_strip_3b32_thread_status", - friendly_name="Nanoleaf Strip 3B32 Thread Status", - unique_id="00:00:00:00:00:00_1_31_117", - entity_category=EntityCategory.DIAGNOSTIC, - capabilities={ - "options": [ - "border_router", - "child", - "detached", - "disabled", - "joining", - "leader", - "router", - ] - }, - state="border_router", - ), - ], - ), - ) diff --git a/tests/components/homekit_controller/specific_devices/test_netamo_smart_co_alarm.py b/tests/components/homekit_controller/specific_devices/test_netamo_smart_co_alarm.py deleted file mode 100644 index 71807871cc1..00000000000 --- a/tests/components/homekit_controller/specific_devices/test_netamo_smart_co_alarm.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Regression tests for Netamo Smart CO Alarm. - -https://github.com/home-assistant/core/issues/78903 -""" -from homeassistant.const import EntityCategory -from homeassistant.core import HomeAssistant - -from ..common import ( - HUB_TEST_ACCESSORY_ID, - DeviceTestInfo, - EntityTestInfo, - assert_devices_and_entities_created, - setup_accessories_from_file, - setup_test_accessories, -) - - -async def test_netamo_smart_co_alarm_setup(hass: HomeAssistant) -> None: - """Test that a Netamo Smart CO Alarm can be correctly setup in HA.""" - accessories = await setup_accessories_from_file(hass, "netamo_smart_co_alarm.json") - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="Smart CO Alarm", - model="Smart CO Alarm", - manufacturer="Netatmo", - sw_version="1.0.3", - hw_version="0", - serial_number="1234", - devices=[], - entities=[ - EntityTestInfo( - entity_id="binary_sensor.smart_co_alarm_carbon_monoxide_sensor", - friendly_name="Smart CO Alarm Carbon Monoxide Sensor", - unique_id="00:00:00:00:00:00_1_22", - state="off", - ), - EntityTestInfo( - entity_id="binary_sensor.smart_co_alarm_low_battery", - friendly_name="Smart CO Alarm Low Battery", - entity_category=EntityCategory.DIAGNOSTIC, - unique_id="00:00:00:00:00:00_1_36", - state="off", - ), - ], - ), - ) diff --git a/tests/components/homekit_controller/specific_devices/test_netatmo_home_coach.py b/tests/components/homekit_controller/specific_devices/test_netatmo_home_coach.py deleted file mode 100644 index e9e6749bd36..00000000000 --- a/tests/components/homekit_controller/specific_devices/test_netatmo_home_coach.py +++ /dev/null @@ -1,45 +0,0 @@ -"""Regression tests for Netamo Healthy Home Coach. - -https://github.com/home-assistant/core/issues/73360 -""" -from homeassistant.components.sensor import SensorStateClass -from homeassistant.core import HomeAssistant - -from ..common import ( - HUB_TEST_ACCESSORY_ID, - DeviceTestInfo, - EntityTestInfo, - assert_devices_and_entities_created, - setup_accessories_from_file, - setup_test_accessories, -) - - -async def test_netamo_smart_co_alarm_setup(hass: HomeAssistant) -> None: - """Test that a Netamo Smart CO Alarm can be correctly setup in HA.""" - accessories = await setup_accessories_from_file(hass, "netatmo_home_coach.json") - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="Healthy Home Coach", - model="Healthy Home Coach", - manufacturer="Netatmo", - sw_version="59", - hw_version="", - serial_number="1234", - devices=[], - entities=[ - EntityTestInfo( - entity_id="sensor.healthy_home_coach_noise", - friendly_name="Healthy Home Coach Noise", - unique_id="00:00:00:00:00:00_1_20_21", - state="0", - unit_of_measurement="dB", - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - ), - ], - ), - ) diff --git a/tests/components/homekit_controller/specific_devices/test_rainmachine_pro_8.py b/tests/components/homekit_controller/specific_devices/test_rainmachine_pro_8.py deleted file mode 100644 index 24a4dbe0349..00000000000 --- a/tests/components/homekit_controller/specific_devices/test_rainmachine_pro_8.py +++ /dev/null @@ -1,84 +0,0 @@ -"""Make sure that existing RainMachine support isn't broken. - -https://github.com/home-assistant/core/issues/31745 -""" -from homeassistant.core import HomeAssistant - -from ..common import ( - HUB_TEST_ACCESSORY_ID, - DeviceTestInfo, - EntityTestInfo, - assert_devices_and_entities_created, - setup_accessories_from_file, - setup_test_accessories, -) - - -async def test_rainmachine_pro_8_setup(hass: HomeAssistant) -> None: - """Test that a RainMachine can be correctly setup in HA.""" - accessories = await setup_accessories_from_file(hass, "rainmachine-pro-8.json") - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="RainMachine-00ce4a", - model="SPK5 Pro", - manufacturer="Green Electronics LLC", - sw_version="1.0.4", - hw_version="1", - serial_number="00aa0000aa0a", - devices=[], - entities=[ - EntityTestInfo( - entity_id="switch.rainmachine_00ce4a", - friendly_name="RainMachine-00ce4a", - unique_id="00:00:00:00:00:00_1_512", - state="off", - ), - EntityTestInfo( - entity_id="switch.rainmachine_00ce4a_2", - friendly_name="RainMachine-00ce4a", - unique_id="00:00:00:00:00:00_1_768", - state="off", - ), - EntityTestInfo( - entity_id="switch.rainmachine_00ce4a_3", - friendly_name="RainMachine-00ce4a", - unique_id="00:00:00:00:00:00_1_1024", - state="off", - ), - EntityTestInfo( - entity_id="switch.rainmachine_00ce4a_4", - friendly_name="RainMachine-00ce4a", - unique_id="00:00:00:00:00:00_1_1280", - state="off", - ), - EntityTestInfo( - entity_id="switch.rainmachine_00ce4a_5", - friendly_name="RainMachine-00ce4a", - unique_id="00:00:00:00:00:00_1_1536", - state="off", - ), - EntityTestInfo( - entity_id="switch.rainmachine_00ce4a_6", - friendly_name="RainMachine-00ce4a", - unique_id="00:00:00:00:00:00_1_1792", - state="off", - ), - EntityTestInfo( - entity_id="switch.rainmachine_00ce4a_7", - friendly_name="RainMachine-00ce4a", - unique_id="00:00:00:00:00:00_1_2048", - state="off", - ), - EntityTestInfo( - entity_id="switch.rainmachine_00ce4a_8", - friendly_name="RainMachine-00ce4a", - unique_id="00:00:00:00:00:00_1_2304", - state="off", - ), - ], - ), - ) diff --git a/tests/components/homekit_controller/specific_devices/test_ryse_smart_bridge.py b/tests/components/homekit_controller/specific_devices/test_ryse_smart_bridge.py deleted file mode 100644 index d56aa4ad481..00000000000 --- a/tests/components/homekit_controller/specific_devices/test_ryse_smart_bridge.py +++ /dev/null @@ -1,230 +0,0 @@ -"""Test against characteristics captured from a ryse smart bridge platforms.""" -from homeassistant.components.cover import CoverEntityFeature -from homeassistant.components.sensor import SensorStateClass -from homeassistant.const import PERCENTAGE, EntityCategory -from homeassistant.core import HomeAssistant - -from ..common import ( - HUB_TEST_ACCESSORY_ID, - DeviceTestInfo, - EntityTestInfo, - assert_devices_and_entities_created, - setup_accessories_from_file, - setup_test_accessories, -) - -RYSE_SUPPORTED_FEATURES = ( - CoverEntityFeature.CLOSE | CoverEntityFeature.SET_POSITION | CoverEntityFeature.OPEN -) - - -async def test_ryse_smart_bridge_setup(hass: HomeAssistant) -> None: - """Test that a Ryse smart bridge can be correctly setup in HA.""" - accessories = await setup_accessories_from_file(hass, "ryse_smart_bridge.json") - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="RYSE SmartBridge", - model="RYSE SmartBridge", - manufacturer="RYSE Inc.", - sw_version="1.3.0", - hw_version="0101.3521.0436", - devices=[ - DeviceTestInfo( - unique_id="00:00:00:00:00:00:aid:2", - name="Master Bath South", - model="RYSE Shade", - manufacturer="RYSE Inc.", - sw_version="3.0.8", - hw_version="1.0.0", - serial_number="", - devices=[], - entities=[ - EntityTestInfo( - entity_id="cover.master_bath_south_ryse_shade", - friendly_name="Master Bath South RYSE Shade", - unique_id="00:00:00:00:00:00_2_48", - supported_features=RYSE_SUPPORTED_FEATURES, - state="closed", - ), - EntityTestInfo( - entity_id="sensor.master_bath_south_ryse_shade_battery", - friendly_name="Master Bath South RYSE Shade Battery", - entity_category=EntityCategory.DIAGNOSTIC, - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unique_id="00:00:00:00:00:00_2_64", - unit_of_measurement=PERCENTAGE, - state="100", - ), - ], - ), - DeviceTestInfo( - unique_id="00:00:00:00:00:00:aid:3", - name="RYSE SmartShade", - model="RYSE Shade", - manufacturer="RYSE Inc.", - sw_version="", - hw_version="", - serial_number="", - devices=[], - entities=[ - EntityTestInfo( - entity_id="cover.ryse_smartshade_ryse_shade", - friendly_name="RYSE SmartShade RYSE Shade", - unique_id="00:00:00:00:00:00_3_48", - supported_features=RYSE_SUPPORTED_FEATURES, - state="open", - ), - EntityTestInfo( - entity_id="sensor.ryse_smartshade_ryse_shade_battery", - friendly_name="RYSE SmartShade RYSE Shade Battery", - entity_category=EntityCategory.DIAGNOSTIC, - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unique_id="00:00:00:00:00:00_3_64", - unit_of_measurement=PERCENTAGE, - state="100", - ), - ], - ), - ], - entities=[], - ), - ) - - -async def test_ryse_smart_bridge_four_shades_setup(hass: HomeAssistant) -> None: - """Test that a Ryse smart bridge with four shades can be correctly setup in HA.""" - accessories = await setup_accessories_from_file( - hass, "ryse_smart_bridge_four_shades.json" - ) - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="RYSE SmartBridge", - model="RYSE SmartBridge", - manufacturer="RYSE Inc.", - sw_version="1.3.0", - hw_version="0401.3521.0679", - devices=[ - DeviceTestInfo( - unique_id="00:00:00:00:00:00:aid:2", - name="LR Left", - model="RYSE Shade", - manufacturer="RYSE Inc.", - sw_version="3.0.8", - hw_version="1.0.0", - serial_number="", - devices=[], - entities=[ - EntityTestInfo( - entity_id="cover.lr_left_ryse_shade", - friendly_name="LR Left RYSE Shade", - unique_id="00:00:00:00:00:00_2_48", - supported_features=RYSE_SUPPORTED_FEATURES, - state="closed", - ), - EntityTestInfo( - entity_id="sensor.lr_left_ryse_shade_battery", - friendly_name="LR Left RYSE Shade Battery", - entity_category=EntityCategory.DIAGNOSTIC, - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unique_id="00:00:00:00:00:00_2_64", - unit_of_measurement=PERCENTAGE, - state="89", - ), - ], - ), - DeviceTestInfo( - unique_id="00:00:00:00:00:00:aid:3", - name="LR Right", - model="RYSE Shade", - manufacturer="RYSE Inc.", - sw_version="3.0.8", - hw_version="1.0.0", - serial_number="", - devices=[], - entities=[ - EntityTestInfo( - entity_id="cover.lr_right_ryse_shade", - friendly_name="LR Right RYSE Shade", - unique_id="00:00:00:00:00:00_3_48", - supported_features=RYSE_SUPPORTED_FEATURES, - state="closed", - ), - EntityTestInfo( - entity_id="sensor.lr_right_ryse_shade_battery", - friendly_name="LR Right RYSE Shade Battery", - entity_category=EntityCategory.DIAGNOSTIC, - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unique_id="00:00:00:00:00:00_3_64", - unit_of_measurement=PERCENTAGE, - state="100", - ), - ], - ), - DeviceTestInfo( - unique_id="00:00:00:00:00:00:aid:4", - name="BR Left", - model="RYSE Shade", - manufacturer="RYSE Inc.", - sw_version="3.0.8", - hw_version="1.0.0", - serial_number="", - devices=[], - entities=[ - EntityTestInfo( - entity_id="cover.br_left_ryse_shade", - friendly_name="BR Left RYSE Shade", - unique_id="00:00:00:00:00:00_4_48", - supported_features=RYSE_SUPPORTED_FEATURES, - state="open", - ), - EntityTestInfo( - entity_id="sensor.br_left_ryse_shade_battery", - friendly_name="BR Left RYSE Shade Battery", - entity_category=EntityCategory.DIAGNOSTIC, - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unique_id="00:00:00:00:00:00_4_64", - unit_of_measurement=PERCENTAGE, - state="100", - ), - ], - ), - DeviceTestInfo( - unique_id="00:00:00:00:00:00:aid:5", - name="RZSS", - model="RYSE Shade", - manufacturer="RYSE Inc.", - sw_version="3.0.8", - hw_version="1.0.0", - serial_number="", - devices=[], - entities=[ - EntityTestInfo( - entity_id="cover.rzss_ryse_shade", - friendly_name="RZSS RYSE Shade", - unique_id="00:00:00:00:00:00_5_48", - supported_features=RYSE_SUPPORTED_FEATURES, - state="open", - ), - EntityTestInfo( - entity_id="sensor.rzss_ryse_shade_battery", - entity_category=EntityCategory.DIAGNOSTIC, - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - friendly_name="RZSS RYSE Shade Battery", - unique_id="00:00:00:00:00:00_5_64", - unit_of_measurement=PERCENTAGE, - state="0", - ), - ], - ), - ], - entities=[], - ), - ) diff --git a/tests/components/homekit_controller/specific_devices/test_schlage_sense.py b/tests/components/homekit_controller/specific_devices/test_schlage_sense.py deleted file mode 100644 index 6ed0a97c23d..00000000000 --- a/tests/components/homekit_controller/specific_devices/test_schlage_sense.py +++ /dev/null @@ -1,40 +0,0 @@ -"""Make sure that Schlage Sense is enumerated properly.""" -from homeassistant.core import HomeAssistant - -from ..common import ( - HUB_TEST_ACCESSORY_ID, - DeviceTestInfo, - EntityTestInfo, - assert_devices_and_entities_created, - setup_accessories_from_file, - setup_test_accessories, -) - - -async def test_schlage_sense_setup(hass: HomeAssistant) -> None: - """Test that the accessory can be correctly setup in HA.""" - accessories = await setup_accessories_from_file(hass, "schlage_sense.json") - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="SENSE ", - model="BE479CAM619", - manufacturer="Schlage ", - sw_version="004.027.000", - hw_version="1.3.0", - serial_number="AAAAAAA000", - devices=[], - entities=[ - EntityTestInfo( - entity_id="lock.sense_lock_mechanism", - friendly_name="SENSE Lock Mechanism", - unique_id="00:00:00:00:00:00_1_30", - supported_features=0, - state="unknown", - ), - ], - ), - ) diff --git a/tests/components/homekit_controller/specific_devices/test_simpleconnect_fan.py b/tests/components/homekit_controller/specific_devices/test_simpleconnect_fan.py deleted file mode 100644 index 59e7d2855e4..00000000000 --- a/tests/components/homekit_controller/specific_devices/test_simpleconnect_fan.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Test against characteristics captured from a SIMPLEconnect Fan. - -https://github.com/home-assistant/core/issues/26180 -""" -from homeassistant.components.fan import FanEntityFeature -from homeassistant.core import HomeAssistant - -from ..common import ( - HUB_TEST_ACCESSORY_ID, - DeviceTestInfo, - EntityTestInfo, - assert_devices_and_entities_created, - setup_accessories_from_file, - setup_test_accessories, -) - - -async def test_simpleconnect_fan_setup(hass: HomeAssistant) -> None: - """Test that a SIMPLEconnect fan can be correctly setup in HA.""" - accessories = await setup_accessories_from_file(hass, "simpleconnect_fan.json") - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="SIMPLEconnect Fan-06F674", - model="SIMPLEconnect", - manufacturer="Hunter Fan", - sw_version="", - hw_version="", - serial_number="1234567890abcd", - devices=[], - entities=[ - EntityTestInfo( - entity_id="fan.simpleconnect_fan_06f674_hunter_fan", - friendly_name="SIMPLEconnect Fan-06F674 Hunter Fan", - unique_id="00:00:00:00:00:00_1_8", - supported_features=FanEntityFeature.DIRECTION - | FanEntityFeature.SET_SPEED, - capabilities={ - "preset_modes": None, - }, - state="off", - ), - ], - ), - ) diff --git a/tests/components/homekit_controller/specific_devices/test_velux_gateway.py b/tests/components/homekit_controller/specific_devices/test_velux_gateway.py deleted file mode 100644 index 854de4b89d8..00000000000 --- a/tests/components/homekit_controller/specific_devices/test_velux_gateway.py +++ /dev/null @@ -1,100 +0,0 @@ -"""Test against characteristics captured from a Velux Gateway. - -https://github.com/home-assistant/core/issues/44314 -""" -from homeassistant.components.cover import CoverEntityFeature -from homeassistant.components.sensor import SensorStateClass -from homeassistant.const import ( - CONCENTRATION_PARTS_PER_MILLION, - PERCENTAGE, - UnitOfTemperature, -) -from homeassistant.core import HomeAssistant - -from ..common import ( - HUB_TEST_ACCESSORY_ID, - DeviceTestInfo, - EntityTestInfo, - assert_devices_and_entities_created, - setup_accessories_from_file, - setup_test_accessories, -) - - -async def test_velux_cover_setup(hass: HomeAssistant) -> None: - """Test that a velux gateway can be correctly setup in HA.""" - accessories = await setup_accessories_from_file(hass, "velux_gateway.json") - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="VELUX Gateway", - model="VELUX Gateway", - manufacturer="VELUX", - sw_version="70", - hw_version="", - serial_number="a1a11a1", - devices=[ - DeviceTestInfo( - name="VELUX Window", - model="VELUX Window", - manufacturer="VELUX", - sw_version="48", - hw_version="", - serial_number="1111111a114a111a", - unique_id="00:00:00:00:00:00:aid:3", - devices=[], - entities=[ - EntityTestInfo( - entity_id="cover.velux_window_roof_window", - friendly_name="VELUX Window Roof Window", - unique_id="00:00:00:00:00:00_3_8", - supported_features=CoverEntityFeature.CLOSE - | CoverEntityFeature.SET_POSITION - | CoverEntityFeature.OPEN, - state="closed", - ), - ], - ), - DeviceTestInfo( - name="VELUX Sensor", - model="VELUX Sensor", - manufacturer="VELUX", - sw_version="16", - hw_version="", - serial_number="a11b111", - unique_id="00:00:00:00:00:00:aid:2", - devices=[], - entities=[ - EntityTestInfo( - entity_id="sensor.velux_sensor_temperature_sensor", - friendly_name="VELUX Sensor Temperature sensor", - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unique_id="00:00:00:00:00:00_2_8", - unit_of_measurement=UnitOfTemperature.CELSIUS, - state="18.9", - ), - EntityTestInfo( - entity_id="sensor.velux_sensor_humidity_sensor", - friendly_name="VELUX Sensor Humidity sensor", - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unique_id="00:00:00:00:00:00_2_11", - unit_of_measurement=PERCENTAGE, - state="58", - ), - EntityTestInfo( - entity_id="sensor.velux_sensor_carbon_dioxide_sensor", - friendly_name="VELUX Sensor Carbon Dioxide sensor", - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unique_id="00:00:00:00:00:00_2_14", - unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, - state="400", - ), - ], - ), - ], - entities=[], - ), - ) diff --git a/tests/components/homekit_controller/specific_devices/test_vocolinc_flowerbud.py b/tests/components/homekit_controller/specific_devices/test_vocolinc_flowerbud.py deleted file mode 100644 index fed8f05b0b9..00000000000 --- a/tests/components/homekit_controller/specific_devices/test_vocolinc_flowerbud.py +++ /dev/null @@ -1,78 +0,0 @@ -"""Make sure that Vocolinc Flowerbud is enumerated properly.""" -from homeassistant.components.humidifier import HumidifierEntityFeature -from homeassistant.components.number import NumberMode -from homeassistant.components.sensor import SensorStateClass -from homeassistant.const import PERCENTAGE, EntityCategory -from homeassistant.core import HomeAssistant - -from ..common import ( - HUB_TEST_ACCESSORY_ID, - DeviceTestInfo, - EntityTestInfo, - assert_devices_and_entities_created, - setup_accessories_from_file, - setup_test_accessories, -) - - -async def test_vocolinc_flowerbud_setup(hass: HomeAssistant) -> None: - """Test that a Vocolinc Flowerbud can be correctly setup in HA.""" - accessories = await setup_accessories_from_file(hass, "vocolinc_flowerbud.json") - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="VOCOlinc-Flowerbud-0d324b", - model="Flowerbud", - manufacturer="VOCOlinc", - sw_version="3.121.2", - hw_version="0.1", - serial_number="AM01121849000327", - devices=[], - entities=[ - EntityTestInfo( - entity_id="humidifier.vocolinc_flowerbud_0d324b", - friendly_name="VOCOlinc-Flowerbud-0d324b", - unique_id="00:00:00:00:00:00_1_30", - supported_features=HumidifierEntityFeature.MODES, - capabilities={ - "available_modes": ["normal", "auto"], - "max_humidity": 100.0, - "min_humidity": 0.0, - }, - state="off", - ), - EntityTestInfo( - entity_id="light.vocolinc_flowerbud_0d324b_mood_light", - friendly_name="VOCOlinc-Flowerbud-0d324b Mood Light", - unique_id="00:00:00:00:00:00_1_9", - supported_features=0, - capabilities={"supported_color_modes": ["hs"]}, - state="on", - ), - EntityTestInfo( - entity_id="number.vocolinc_flowerbud_0d324b_spray_quantity", - friendly_name="VOCOlinc-Flowerbud-0d324b Spray Quantity", - unique_id="00:00:00:00:00:00_1_30_38", - capabilities={ - "max": 5, - "min": 1, - "mode": NumberMode.AUTO, - "step": 1, - }, - state="5", - entity_category=EntityCategory.CONFIG, - ), - EntityTestInfo( - entity_id="sensor.vocolinc_flowerbud_0d324b_current_humidity", - friendly_name="VOCOlinc-Flowerbud-0d324b Current Humidity", - unique_id="00:00:00:00:00:00_1_30_33", - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unit_of_measurement=PERCENTAGE, - state="45.0", - ), - ], - ), - ) diff --git a/tests/components/homekit_controller/test_config_flow.py b/tests/components/homekit_controller/test_config_flow.py index c989bc01ff2..3412e41aa17 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/homekit_controller/test_init.py b/tests/components/homekit_controller/test_init.py index 8ffeec093f6..23c6e245ac7 100644 --- a/tests/components/homekit_controller/test_init.py +++ b/tests/components/homekit_controller/test_init.py @@ -1,5 +1,6 @@ """Tests for homekit_controller init.""" from datetime import timedelta +import pathlib from unittest.mock import patch from aiohomekit import AccessoryNotFoundError @@ -7,6 +8,9 @@ from aiohomekit.model import Accessory, Transport from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.services import ServicesTypes from aiohomekit.testing import FakePairing +from attr import asdict +import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.homekit_controller.const import DOMAIN, ENTITY_MAP from homeassistant.config_entries import ConfigEntryState @@ -20,6 +24,8 @@ from homeassistant.util.dt import utcnow from .common import ( Helper, remove_device, + setup_accessories_from_file, + setup_test_accessories, setup_test_accessories_with_controller, setup_test_component, ) @@ -27,6 +33,9 @@ from .common import ( from tests.common import async_fire_time_changed from tests.typing import WebSocketGenerator +FIXTURES_DIR = pathlib.Path(__file__).parent / "fixtures" +FIXTURES = [path.relative_to(FIXTURES_DIR) for path in FIXTURES_DIR.glob("*.json")] + ALIVE_DEVICE_NAME = "testdevice" ALIVE_DEVICE_ENTITY_ID = "light.testdevice" @@ -218,3 +227,57 @@ async def test_ble_device_only_checks_is_available( is_available = True async_fire_time_changed(hass, utcnow() + timedelta(hours=1)) assert hass.states.get("light.testdevice").state == STATE_OFF + + +@pytest.mark.parametrize("example", FIXTURES, ids=lambda val: str(val.stem)) +async def test_snapshots( + hass: HomeAssistant, snapshot: SnapshotAssertion, example: str +) -> None: + """Detect regressions in enumerating a homekit accessory database and building entities.""" + accessories = await setup_accessories_from_file(hass, example) + config_entry, _ = await setup_test_accessories(hass, accessories) + + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + + registry_devices = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + registry_devices.sort(key=lambda device: device.name) + + devices = [] + + for device in registry_devices: + entities = [] + + registry_entities = er.async_entries_for_device( + entity_registry, + device_id=device.id, + include_disabled_entities=True, + ) + registry_entities.sort(key=lambda entity: entity.entity_id) + + for entity_entry in registry_entities: + state_dict = None + if state := hass.states.get(entity_entry.entity_id): + state_dict = dict(state.as_dict()) + state_dict.pop("context", None) + state_dict.pop("last_changed", None) + state_dict.pop("last_updated", None) + + state_dict["attributes"] = dict(state_dict["attributes"]) + state_dict["attributes"].pop("access_token", None) + state_dict["attributes"].pop("entity_picture", None) + + entry = asdict(entity_entry) + entry.pop("id", None) + entry.pop("device_id", None) + + entities.append({"entry": entry, "state": state_dict}) + + device_dict = asdict(device) + device_dict.pop("id", None) + device_dict.pop("via_device_id", None) + devices.append({"device": device_dict, "entities": entities}) + + assert snapshot == devices diff --git a/tests/components/homekit_controller/test_select.py b/tests/components/homekit_controller/test_select.py index d9feebafc76..9cfa0bccda3 100644 --- a/tests/components/homekit_controller/test_select.py +++ b/tests/components/homekit_controller/test_select.py @@ -1,6 +1,7 @@ """Basic checks for HomeKit select entities.""" from aiohomekit.model import Accessory from aiohomekit.model.characteristics import CharacteristicsTypes +from aiohomekit.model.characteristics.const import TemperatureDisplayUnits from aiohomekit.model.services import ServicesTypes from homeassistant.core import HomeAssistant @@ -22,6 +23,16 @@ def create_service_with_ecobee_mode(accessory: Accessory): return service +def create_service_with_temperature_units(accessory: Accessory): + """Define a thermostat with ecobee mode characteristics.""" + service = accessory.add_service(ServicesTypes.TEMPERATURE_SENSOR, add_required=True) + + units = service.add_char(CharacteristicsTypes.TEMPERATURE_UNITS) + units.value = 0 + + return service + + async def test_migrate_unique_id(hass: HomeAssistant, utcnow) -> None: """Test we can migrate a select unique id.""" entity_registry = er.async_get(hass) @@ -125,3 +136,76 @@ async def test_write_current_mode(hass: HomeAssistant, utcnow) -> None: ServicesTypes.THERMOSTAT, {CharacteristicsTypes.VENDOR_ECOBEE_SET_HOLD_SCHEDULE: 2}, ) + + +async def test_read_select(hass: HomeAssistant, utcnow) -> None: + """Test the generic select can read the current value.""" + helper = await setup_test_component(hass, create_service_with_temperature_units) + + # Helper will be for the primary entity, which is the service. Make a helper for the sensor. + select_entity = Helper( + hass, + "select.testdevice_temperature_display_units", + helper.pairing, + helper.accessory, + helper.config_entry, + ) + + state = await select_entity.async_update( + ServicesTypes.TEMPERATURE_SENSOR, + { + CharacteristicsTypes.TEMPERATURE_UNITS: 0, + }, + ) + assert state.state == "celsius" + + state = await select_entity.async_update( + ServicesTypes.TEMPERATURE_SENSOR, + { + CharacteristicsTypes.TEMPERATURE_UNITS: 1, + }, + ) + assert state.state == "fahrenheit" + + +async def test_write_select(hass: HomeAssistant, utcnow) -> None: + """Test can set a value.""" + helper = await setup_test_component(hass, create_service_with_temperature_units) + helper.accessory.services.first(service_type=ServicesTypes.THERMOSTAT) + + # Helper will be for the primary entity, which is the service. Make a helper for the sensor. + current_mode = Helper( + hass, + "select.testdevice_temperature_display_units", + helper.pairing, + helper.accessory, + helper.config_entry, + ) + + await hass.services.async_call( + "select", + "select_option", + { + "entity_id": "select.testdevice_temperature_display_units", + "option": "fahrenheit", + }, + blocking=True, + ) + current_mode.async_assert_service_values( + ServicesTypes.TEMPERATURE_SENSOR, + {CharacteristicsTypes.TEMPERATURE_UNITS: TemperatureDisplayUnits.FAHRENHEIT}, + ) + + await hass.services.async_call( + "select", + "select_option", + { + "entity_id": "select.testdevice_temperature_display_units", + "option": "celsius", + }, + blocking=True, + ) + current_mode.async_assert_service_values( + ServicesTypes.TEMPERATURE_SENSOR, + {CharacteristicsTypes.TEMPERATURE_UNITS: TemperatureDisplayUnits.CELSIUS}, + ) diff --git a/tests/components/homewizard/test_config_flow.py b/tests/components/homewizard/test_config_flow.py index 7a1652549d7..7c6fb0bdb0d 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 @@ async def test_discovery_flow_during_onboarding_disabled_api( 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 8406d76803a..876050586d2 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 00000000000..3077fc747de --- /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 92caa29b71f..7bd76cb8522 100644 --- a/tests/components/honeywell/test_climate.py +++ b/tests/components/honeywell/test_climate.py @@ -37,6 +37,7 @@ from homeassistant.const import ( 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 00000000000..aafc50d5545 --- /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 f7629fa958e..73dda8ed223 100644 --- a/tests/components/honeywell/test_init.py +++ b/tests/components/honeywell/test_init.py @@ -12,6 +12,7 @@ from homeassistant.components.honeywell.const import ( 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/fixtures/v2_resources.json b/tests/components/hue/fixtures/v2_resources.json index 371975e12a5..24f433f539c 100644 --- a/tests/components/hue/fixtures/v2_resources.json +++ b/tests/components/hue/fixtures/v2_resources.json @@ -2221,5 +2221,113 @@ "id": "52612630-841e-4d39-9763-60346a0da759", "is_configured": true, "type": "geolocation" + }, + { + "id": "0240be0e-8b79-4a53-b9bb-b17fa14d7e75", + "product_data": { + "model_id": "SOC001", + "manufacturer_name": "Signify Netherlands B.V.", + "product_name": "Hue secure contact sensor", + "product_archetype": "unknown_archetype", + "certified": true, + "software_version": "2.67.9", + "hardware_platform_type": "100b-125" + }, + "metadata": { + "name": "Test contact sensor", + "archetype": "unknown_archetype" + }, + "identify": {}, + "services": [ + { + "rid": "18802b4a-b2f6-45dc-8813-99cde47f3a4a", + "rtype": "contact" + }, + { + "rid": "d7fcfab0-69e1-4afb-99df-6ed505211db4", + "rtype": "tamper" + } + ], + "type": "device" + }, + { + "id": "18802b4a-b2f6-45dc-8813-99cde47f3a4a", + "owner": { + "rid": "0240be0e-8b79-4a53-b9bb-b17fa14d7e75", + "rtype": "device" + }, + "enabled": true, + "contact_report": { + "changed": "2023-09-27T10:01:36.968Z", + "state": "contact" + }, + "type": "contact" + }, + { + "id": "d7fcfab0-69e1-4afb-99df-6ed505211db4", + "owner": { + "rid": "0240be0e-8b79-4a53-b9bb-b17fa14d7e75", + "rtype": "device" + }, + "tamper_reports": [ + { + "changed": "2023-09-25T10:02:08.774Z", + "source": "battery_door", + "state": "not_tampered" + } + ], + "type": "tamper" + }, + { + "id": "1cbda90c-b675-46b0-9e97-278e7e7857ed", + "id_v1": "/sensors/249", + "product_data": { + "model_id": "CAMERA", + "manufacturer_name": "Signify Netherlands B.V.", + "product_name": "Fake Hue Test Camera", + "product_archetype": "unknown_archetype", + "certified": true, + "software_version": "0.0.0", + "hardware_platform_type": "0" + }, + "metadata": { + "name": "Test Camera", + "archetype": "unknown_archetype" + }, + "identify": {}, + "usertest": { + "status": "set", + "usertest": false + }, + "services": [ + { + "rid": "d9f2cfee-5879-426b-aa1f-553af8f38176", + "rtype": "camera_motion" + } + ], + "type": "device" + }, + { + "id": "d9f2cfee-5879-426b-aa1f-553af8f38176", + "id_v1": "/sensors/249", + "owner": { + "rid": "1cbda90c-b675-46b0-9e97-278e7e7857ed", + "rtype": "device" + }, + "enabled": true, + "motion": { + "motion": true, + "motion_valid": true, + "motion_report": { + "changed": "2023-09-27T10:06:41.822Z", + "motion": true + } + }, + "sensitivity": { + "status": "set", + "sensitivity": 2, + "sensitivity_max": 4 + }, + "type": "motion" } ] diff --git a/tests/components/hue/test_binary_sensor.py b/tests/components/hue/test_binary_sensor.py index 7750f4a6795..3846f17aa76 100644 --- a/tests/components/hue/test_binary_sensor.py +++ b/tests/components/hue/test_binary_sensor.py @@ -14,8 +14,8 @@ async def test_binary_sensors( await setup_platform(hass, mock_bridge_v2, "binary_sensor") # there shouldn't have been any requests at this point assert len(mock_bridge_v2.mock_requests) == 0 - # 2 binary_sensors should be created from test data - assert len(hass.states.async_all()) == 2 + # 5 binary_sensors should be created from test data + assert len(hass.states.async_all()) == 5 # test motion sensor sensor = hass.states.get("binary_sensor.hue_motion_sensor_motion") @@ -23,7 +23,6 @@ async def test_binary_sensors( assert sensor.state == "off" assert sensor.name == "Hue motion sensor Motion" assert sensor.attributes["device_class"] == "motion" - assert sensor.attributes["motion_valid"] is True # test entertainment room active sensor sensor = hass.states.get( @@ -34,6 +33,51 @@ async def test_binary_sensors( assert sensor.name == "Entertainmentroom 1: Entertainment Configuration" assert sensor.attributes["device_class"] == "running" + # test contact sensor + sensor = hass.states.get("binary_sensor.test_contact_sensor_contact") + assert sensor is not None + assert sensor.state == "off" + assert sensor.name == "Test contact sensor Contact" + assert sensor.attributes["device_class"] == "opening" + # test contact sensor disabled == state unknown + mock_bridge_v2.api.emit_event( + "update", + { + "enabled": False, + "id": "18802b4a-b2f6-45dc-8813-99cde47f3a4a", + "type": "contact", + }, + ) + await hass.async_block_till_done() + sensor = hass.states.get("binary_sensor.test_contact_sensor_contact") + assert sensor.state == "unknown" + + # test tamper sensor + sensor = hass.states.get("binary_sensor.test_contact_sensor_tamper") + assert sensor is not None + assert sensor.state == "off" + assert sensor.name == "Test contact sensor Tamper" + assert sensor.attributes["device_class"] == "tamper" + # test tamper sensor when no tamper reports exist + mock_bridge_v2.api.emit_event( + "update", + { + "id": "d7fcfab0-69e1-4afb-99df-6ed505211db4", + "tamper_reports": [], + "type": "tamper", + }, + ) + await hass.async_block_till_done() + sensor = hass.states.get("binary_sensor.test_contact_sensor_tamper") + assert sensor.state == "off" + + # test camera_motion sensor + sensor = hass.states.get("binary_sensor.test_camera_motion") + assert sensor is not None + assert sensor.state == "on" + assert sensor.name == "Test Camera Motion" + assert sensor.attributes["device_class"] == "motion" + async def test_binary_sensor_add_update(hass: HomeAssistant, mock_bridge_v2) -> None: """Test if binary_sensor get added/updated from events.""" diff --git a/tests/components/hue/test_config_flow.py b/tests/components/hue/test_config_flow.py index 6fa03e1de13..29b94b17da1 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/hue/test_sensor_v2.py b/tests/components/hue/test_sensor_v2.py index 91eccc2c984..45e39e94119 100644 --- a/tests/components/hue/test_sensor_v2.py +++ b/tests/components/hue/test_sensor_v2.py @@ -28,7 +28,6 @@ async def test_sensors( assert sensor.attributes["device_class"] == "temperature" assert sensor.attributes["state_class"] == "measurement" assert sensor.attributes["unit_of_measurement"] == "°C" - assert sensor.attributes["temperature_valid"] is True # test illuminance sensor sensor = hass.states.get("sensor.hue_motion_sensor_illuminance") @@ -39,7 +38,6 @@ async def test_sensors( assert sensor.attributes["state_class"] == "measurement" assert sensor.attributes["unit_of_measurement"] == "lx" assert sensor.attributes["light_level"] == 18027 - assert sensor.attributes["light_level_valid"] is True # test battery sensor sensor = hass.states.get("sensor.wall_switch_with_2_controls_battery") diff --git a/tests/components/hue/test_switch.py b/tests/components/hue/test_switch.py index c8fa417b12c..a576b88a7c3 100644 --- a/tests/components/hue/test_switch.py +++ b/tests/components/hue/test_switch.py @@ -14,8 +14,8 @@ async def test_switch( await setup_platform(hass, mock_bridge_v2, "switch") # there shouldn't have been any requests at this point assert len(mock_bridge_v2.mock_requests) == 0 - # 2 entities should be created from test data - assert len(hass.states.async_all()) == 2 + # 3 entities should be created from test data + assert len(hass.states.async_all()) == 3 # test config switch to enable/disable motion sensor test_entity = hass.states.get("switch.hue_motion_sensor_motion") diff --git a/tests/components/hunterdouglas_powerview/test_config_flow.py b/tests/components/hunterdouglas_powerview/test_config_flow.py index 943de66baac..f39b4c1f68e 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 homeassistant.core import HomeAssistant 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 @@ HOMEKIT_DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( ) 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/hydrawise/__init__.py b/tests/components/hydrawise/__init__.py new file mode 100644 index 00000000000..582d20ba2df --- /dev/null +++ b/tests/components/hydrawise/__init__.py @@ -0,0 +1 @@ +"""Tests for the Hydrawise integration.""" diff --git a/tests/components/hydrawise/conftest.py b/tests/components/hydrawise/conftest.py new file mode 100644 index 00000000000..30989018152 --- /dev/null +++ b/tests/components/hydrawise/conftest.py @@ -0,0 +1,104 @@ +"""Common fixtures for the Hydrawise tests.""" + +from collections.abc import Generator +from typing import Any +from unittest.mock import AsyncMock, Mock, patch + +import pytest + +from homeassistant.components.hydrawise.const import DOMAIN +from homeassistant.const import CONF_API_KEY +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.hydrawise.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_pydrawise( + mock_controller: dict[str, Any], + mock_zones: list[dict[str, Any]], +) -> Generator[Mock, None, None]: + """Mock LegacyHydrawise.""" + with patch("pydrawise.legacy.LegacyHydrawise", autospec=True) as mock_pydrawise: + mock_pydrawise.return_value.controller_info = {"controllers": [mock_controller]} + mock_pydrawise.return_value.current_controller = mock_controller + mock_pydrawise.return_value.controller_status = {"relays": mock_zones} + mock_pydrawise.return_value.relays = mock_zones + yield mock_pydrawise.return_value + + +@pytest.fixture +def mock_controller() -> dict[str, Any]: + """Mock Hydrawise controller.""" + return { + "name": "Home Controller", + "last_contact": 1693292420, + "serial_number": "0310b36090", + "controller_id": 52496, + "status": "Unknown", + } + + +@pytest.fixture +def mock_zones() -> list[dict[str, Any]]: + """Mock Hydrawise zones.""" + return [ + { + "name": "Zone One", + "period": 259200, + "relay": 1, + "relay_id": 5965394, + "run": 1800, + "stop": 1, + "time": 330597, + "timestr": "Sat", + "type": 1, + }, + { + "name": "Zone Two", + "period": 259200, + "relay": 2, + "relay_id": 5965395, + "run": 1788, + "stop": 1, + "time": 1, + "timestr": "Now", + "type": 106, + }, + ] + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock ConfigEntry.""" + return MockConfigEntry( + title="Hydrawise", + domain=DOMAIN, + data={ + CONF_API_KEY: "abc123", + }, + unique_id="hydrawise-customerid", + ) + + +@pytest.fixture +async def mock_added_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_pydrawise: Mock, +) -> MockConfigEntry: + """Mock ConfigEntry that's been added to HA.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert DOMAIN in hass.config_entries.async_domains() + return mock_config_entry diff --git a/tests/components/hydrawise/test_config_flow.py b/tests/components/hydrawise/test_config_flow.py new file mode 100644 index 00000000000..c9efbea507e --- /dev/null +++ b/tests/components/hydrawise/test_config_flow.py @@ -0,0 +1,213 @@ +"""Test the Hydrawise config flow.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from requests.exceptions import ConnectTimeout, HTTPError + +from homeassistant import config_entries +from homeassistant.components.hydrawise.const import DOMAIN +from homeassistant.const import CONF_API_KEY, CONF_SCAN_INTERVAL +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +import homeassistant.helpers.issue_registry as ir + +from tests.common import MockConfigEntry + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +@patch("pydrawise.legacy.LegacyHydrawise") +async def test_form( + mock_api: MagicMock, 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 + assert result["step_id"] == "user" + assert result["errors"] == {} + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {"api_key": "abc123"} + ) + mock_api.return_value.customer_id = 12345 + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "Hydrawise" + assert result2["data"] == {"api_key": "abc123"} + assert len(mock_setup_entry.mock_calls) == 1 + + +@patch("pydrawise.legacy.LegacyHydrawise") +async def test_form_api_error(mock_api: MagicMock, hass: HomeAssistant) -> None: + """Test we handle API errors.""" + mock_api.side_effect = HTTPError + init_result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + data = {"api_key": "abc123"} + result = await hass.config_entries.flow.async_configure( + init_result["flow_id"], data + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + mock_api.side_effect = None + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], data) + assert result2["type"] == FlowResultType.CREATE_ENTRY + + +@patch("pydrawise.legacy.LegacyHydrawise") +async def test_form_connect_timeout(mock_api: MagicMock, hass: HomeAssistant) -> None: + """Test we handle API errors.""" + mock_api.side_effect = ConnectTimeout + init_result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + data = {"api_key": "abc123"} + result = await hass.config_entries.flow.async_configure( + init_result["flow_id"], data + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "timeout_connect"} + + mock_api.side_effect = None + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], data) + assert result2["type"] == FlowResultType.CREATE_ENTRY + + +@patch("pydrawise.legacy.LegacyHydrawise") +async def test_flow_import_success(mock_api: MagicMock, hass: HomeAssistant) -> None: + """Test that we can import a YAML config.""" + mock_api.return_value.status = "All good!" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + CONF_API_KEY: "__api_key__", + CONF_SCAN_INTERVAL: 120, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Hydrawise" + assert result["data"] == { + CONF_API_KEY: "__api_key__", + } + + issue_registry = ir.async_get(hass) + issue = issue_registry.async_get_issue( + HOMEASSISTANT_DOMAIN, "deprecated_yaml_hydrawise" + ) + assert issue.translation_key == "deprecated_yaml" + + +@patch("pydrawise.legacy.LegacyHydrawise", side_effect=HTTPError) +async def test_flow_import_api_error(mock_api: MagicMock, hass: HomeAssistant) -> None: + """Test that we handle API errors on YAML import.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + CONF_API_KEY: "__api_key__", + CONF_SCAN_INTERVAL: 120, + }, + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + issue_registry = ir.async_get(hass) + issue = issue_registry.async_get_issue( + DOMAIN, "deprecated_yaml_import_issue_cannot_connect" + ) + assert issue.translation_key == "deprecated_yaml_import_issue" + + +@patch("pydrawise.legacy.LegacyHydrawise", side_effect=ConnectTimeout) +async def test_flow_import_connect_timeout( + mock_api: MagicMock, hass: HomeAssistant +) -> None: + """Test that we handle connection timeouts on YAML import.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + CONF_API_KEY: "__api_key__", + CONF_SCAN_INTERVAL: 120, + }, + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "timeout_connect" + + issue_registry = ir.async_get(hass) + issue = issue_registry.async_get_issue( + DOMAIN, "deprecated_yaml_import_issue_timeout_connect" + ) + assert issue.translation_key == "deprecated_yaml_import_issue" + + +@patch("pydrawise.legacy.LegacyHydrawise") +async def test_flow_import_no_status(mock_api: MagicMock, hass: HomeAssistant) -> None: + """Test we handle a lack of API status on YAML import.""" + mock_api.return_value.status = None + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + CONF_API_KEY: "__api_key__", + CONF_SCAN_INTERVAL: 120, + }, + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "unknown" + + issue_registry = ir.async_get(hass) + issue = issue_registry.async_get_issue( + DOMAIN, "deprecated_yaml_import_issue_unknown" + ) + assert issue.translation_key == "deprecated_yaml_import_issue" + + +@patch("pydrawise.legacy.LegacyHydrawise") +async def test_flow_import_already_imported( + mock_api: MagicMock, hass: HomeAssistant +) -> None: + """Test that we can handle a YAML config already imported.""" + mock_config_entry = MockConfigEntry( + title="Hydrawise", + domain=DOMAIN, + data={ + CONF_API_KEY: "__api_key__", + }, + unique_id="hydrawise-CUSTOMER_ID", + ) + mock_config_entry.add_to_hass(hass) + + mock_api.return_value.customer_id = "CUSTOMER_ID" + mock_api.return_value.status = "All good!" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + CONF_API_KEY: "__api_key__", + CONF_SCAN_INTERVAL: 120, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result.get("reason") == "already_configured" + + issue_registry = ir.async_get(hass) + issue = issue_registry.async_get_issue( + HOMEASSISTANT_DOMAIN, "deprecated_yaml_hydrawise" + ) + assert issue.translation_key == "deprecated_yaml" diff --git a/tests/components/hydrawise/test_device.py b/tests/components/hydrawise/test_device.py new file mode 100644 index 00000000000..05c402faca7 --- /dev/null +++ b/tests/components/hydrawise/test_device.py @@ -0,0 +1,36 @@ +"""Tests for Hydrawise devices.""" + +from unittest.mock import Mock + +from homeassistant.components.hydrawise.const import DOMAIN +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + + +def test_zones_in_device_registry( + hass: HomeAssistant, mock_added_config_entry: ConfigEntry, mock_pydrawise: Mock +) -> None: + """Test that devices are added to the device registry.""" + device_registry = dr.async_get(hass) + + device1 = device_registry.async_get_device(identifiers={(DOMAIN, "5965394")}) + assert device1 is not None + assert device1.name == "Zone One" + assert device1.manufacturer == "Hydrawise" + + device2 = device_registry.async_get_device(identifiers={(DOMAIN, "5965395")}) + assert device2 is not None + assert device2.name == "Zone Two" + assert device2.manufacturer == "Hydrawise" + + +def test_controller_in_device_registry( + hass: HomeAssistant, mock_added_config_entry: ConfigEntry, mock_pydrawise: Mock +) -> None: + """Test that devices are added to the device registry.""" + device_registry = dr.async_get(hass) + device = device_registry.async_get_device(identifiers={(DOMAIN, "52496")}) + assert device is not None + assert device.name == "Home Controller" + assert device.manufacturer == "Hydrawise" diff --git a/tests/components/idasen_desk/__init__.py b/tests/components/idasen_desk/__init__.py new file mode 100644 index 00000000000..7e8becc4689 --- /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 00000000000..736bc6346ce --- /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 00000000000..8635e5bfddc --- /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 00000000000..a9c74be7081 --- /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 00000000000..e596f0fe000 --- /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 e7fca106ff7..ec864fd4665 100644 --- a/tests/components/imap/const.py +++ b/tests/components/imap/const.py @@ -22,6 +22,7 @@ TEST_MESSAGE_HEADERS2 = ( 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 b4ee11ba787..ceda841202c 100644 --- a/tests/components/imap/test_init.py +++ b/tests/components/imap/test_init.py @@ -512,6 +512,7 @@ async def test_reset_last_message( 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/ipma/__init__.py b/tests/components/ipma/__init__.py index 827481c60de..02a61f0b201 100644 --- a/tests/components/ipma/__init__.py +++ b/tests/components/ipma/__init__.py @@ -27,6 +27,14 @@ class MockLocation: ) return RCM("some place", 3, (0, 0)) + async def uv_risk(self, api): + """Mock UV Index.""" + UV = namedtuple( + "UV", + ["idPeriodo", "intervaloHora", "data", "globalIdLocal", "iUv"], + ) + return UV(0, "0", datetime.now(), 0, 5.7) + async def observation(self, api): """Mock Observation.""" Observation = namedtuple( diff --git a/tests/components/ipma/test_sensor.py b/tests/components/ipma/test_sensor.py index cbbad9c590f..d5f6a3ab5bb 100644 --- a/tests/components/ipma/test_sensor.py +++ b/tests/components/ipma/test_sensor.py @@ -10,10 +10,7 @@ from tests.common import MockConfigEntry async def test_ipma_fire_risk_create_sensors(hass): """Test creation of fire risk sensors.""" - with patch( - "pyipma.location.Location.get", - return_value=MockLocation(), - ): + with patch("pyipma.location.Location.get", return_value=MockLocation()): entry = MockConfigEntry(domain="ipma", data=ENTRY_CONFIG) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -22,3 +19,17 @@ async def test_ipma_fire_risk_create_sensors(hass): state = hass.states.get("sensor.hometown_fire_risk") assert state.state == "3" + + +async def test_ipma_uv_index_create_sensors(hass): + """Test creation of uv index sensors.""" + + with patch("pyipma.location.Location.get", return_value=MockLocation()): + entry = MockConfigEntry(domain="ipma", data=ENTRY_CONFIG) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("sensor.hometown_uv_index") + + assert state.state == "6" diff --git a/tests/components/ipp/__init__.py b/tests/components/ipp/__init__.py index f66630b2a69..ca374bd7e5e 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_USER_INPUT = { 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_IPP_SERVICE_INFO = zeroconf.ZeroconfServiceInfo( 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/test_config_flow.py b/tests/components/ipp/test_config_flow.py index 0daf8a0f7e0..5dd6c1af5bf 100644 --- a/tests/components/ipp/test_config_flow.py +++ b/tests/components/ipp/test_config_flow.py @@ -1,5 +1,6 @@ """Tests for the IPP config flow.""" import dataclasses +from ipaddress import ip_address import json from unittest.mock import MagicMock, patch @@ -326,7 +327,9 @@ async def test_zeroconf_with_uuid_device_exists_abort_new_host( """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, host="1.2.3.9") + 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", diff --git a/tests/components/islamic_prayer_times/test_config_flow.py b/tests/components/islamic_prayer_times/test_config_flow.py index a25b8ba0f0b..f331c5bf49b 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 @@ import pytest 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/knx/test_websocket.py b/tests/components/knx/test_websocket.py index 76a9544552f..5e5d46af4a6 100644 --- a/tests/components/knx/test_websocket.py +++ b/tests/components/knx/test_websocket.py @@ -138,6 +138,24 @@ async def test_knx_project_file_remove( assert not hass.data[DOMAIN].project.loaded +async def test_knx_get_project( + hass: HomeAssistant, + knx: KNXTestKit, + hass_ws_client: WebSocketGenerator, + load_knxproj: None, +): + """Test retrieval of kxnproject from store.""" + await knx.setup_integration({}) + client = await hass_ws_client(hass) + assert hass.data[DOMAIN].project.loaded + + await client.send_json({"id": 3, "type": "knx/get_knx_project"}) + res = await client.receive_json() + assert res["success"], res + assert res["result"]["project_loaded"] is True + assert res["result"]["knxproject"] == FIXTURE_PROJECT_DATA + + async def test_knx_group_monitor_info_command( hass: HomeAssistant, knx: KNXTestKit, hass_ws_client: WebSocketGenerator ): diff --git a/tests/components/kodi/util.py b/tests/components/kodi/util.py index 9fb215e2d8a..2b9d819c244 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 @@ TEST_HOST = { "ssl": DEFAULT_SSL, } - TEST_CREDENTIALS = {"username": "username", "password": "password"} @@ -16,8 +17,8 @@ TEST_WS_PORT = {"ws_port": 9090} 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 = zeroconf.ZeroconfServiceInfo( 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 2adea42bed4..1b7da4f864a 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 00000000000..5de380bde1c --- /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 00000000000..a014fc168c6 --- /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 00000000000..4dda341279d --- /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 11426f20e57..bfbb5f66887 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 @@ DEFAULT_ENTRY_TITLE = DEVICE_NAME 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 1fd4479d100..873e21a5cac 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 c9c577e7199..617b6818a64 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.common import load_fixture 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 7f6a1b60511..da26a55a4ef 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 00000000000..a520f7e7c23 --- /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 00000000000..d0970b96019 --- /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 00000000000..54856b91ac3 --- /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 00000000000..8112d98fc8c --- /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 00000000000..0b150a629fe --- /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 00000000000..34964f2b091 --- /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/medcom_ble/__init__.py b/tests/components/medcom_ble/__init__.py new file mode 100644 index 00000000000..e38b8ce8f01 --- /dev/null +++ b/tests/components/medcom_ble/__init__.py @@ -0,0 +1,111 @@ +"""Tests for the Medcom Inspector BLE integration.""" +from __future__ import annotations + +from unittest.mock import patch + +from medcom_ble import MedcomBleDevice, MedcomBleDeviceData + +from homeassistant.components.bluetooth.models import BluetoothServiceInfoBleak + +from tests.components.bluetooth import generate_advertisement_data, generate_ble_device + + +def patch_async_setup_entry(return_value=True): + """Patch async setup entry to return True.""" + return patch( + "homeassistant.components.medcom_ble.async_setup_entry", + return_value=return_value, + ) + + +def patch_async_ble_device_from_address(return_value: BluetoothServiceInfoBleak | None): + """Patch async ble device from address to return a given value.""" + return patch( + "homeassistant.components.bluetooth.async_ble_device_from_address", + return_value=return_value, + ) + + +def patch_medcom_ble(return_value=MedcomBleDevice, side_effect=None): + """Patch medcom-ble device fetcher with given values and effects.""" + return patch.object( + MedcomBleDeviceData, + "update_device", + return_value=return_value, + side_effect=side_effect, + ) + + +MEDCOM_SERVICE_INFO = BluetoothServiceInfoBleak( + name="InspectorBLE-D9A0", + address="a0:d9:5a:57:0b:00", + device=generate_ble_device( + address="a0:d9:5a:57:0b:00", + name="InspectorBLE-D9A0", + ), + rssi=-54, + manufacturer_data={}, + service_data={ + # Sensor data + "d68236af-266f-4486-b42d-80356ed5afb7": bytearray(b" 45,"), + # Manufacturer + "00002a29-0000-1000-8000-00805f9b34fb": bytearray(b"International Medcom"), + # Model + "00002a24-0000-1000-8000-00805f9b34fb": bytearray(b"Inspector-BLE"), + # Identifier + "00002a25-0000-1000-8000-00805f9b34fb": bytearray(b"\xa0\xd9\x5a\x57\x0b\x00"), + # SW Version + "00002a26-0000-1000-8000-00805f9b34fb": bytearray(b"170602"), + # HW Version + "00002a27-0000-1000-8000-00805f9b34fb": bytearray(b"2.0"), + }, + service_uuids=[ + "39b31fec-b63a-4ef7-b163-a7317872007f", + "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", + ], + source="local", + advertisement=generate_advertisement_data( + tx_power=8, + service_uuids=["39b31fec-b63a-4ef7-b163-a7317872007f"], + ), + connectable=True, + time=0, +) + +UNKNOWN_SERVICE_INFO = BluetoothServiceInfoBleak( + name="unknown", + address="00:cc:cc:cc:cc:cc", + rssi=-61, + manufacturer_data={}, + service_data={}, + service_uuids=[], + source="local", + device=generate_ble_device( + "00:cc:cc:cc:cc:cc", + "unknown", + ), + advertisement=generate_advertisement_data( + manufacturer_data={}, + service_uuids=[], + ), + connectable=True, + time=0, +) + +MEDCOM_DEVICE_INFO = MedcomBleDevice( + manufacturer="International Medcom", + hw_version="2.0", + sw_version="170602", + model="Inspector BLE", + model_raw="InspectorBLE-D9A0", + name="Inspector BLE", + identifier="a0d95a570b00", + sensors={ + "cpm": 45, + }, + address="a0:d9:5a:57:0b:00", +) diff --git a/tests/components/medcom_ble/conftest.py b/tests/components/medcom_ble/conftest.py new file mode 100644 index 00000000000..7c5b0dad22e --- /dev/null +++ b/tests/components/medcom_ble/conftest.py @@ -0,0 +1,8 @@ +"""Common fixtures for the Medcom Inspector BLE tests.""" + +import pytest + + +@pytest.fixture(autouse=True) +def mock_bluetooth(enable_bluetooth): + """Auto mock bluetooth.""" diff --git a/tests/components/medcom_ble/test_config_flow.py b/tests/components/medcom_ble/test_config_flow.py new file mode 100644 index 00000000000..620b6811757 --- /dev/null +++ b/tests/components/medcom_ble/test_config_flow.py @@ -0,0 +1,218 @@ +"""Test the Medcom Inspector BLE config flow.""" +from unittest.mock import patch + +from bleak import BleakError +from medcom_ble import MedcomBleDevice + +from homeassistant import config_entries +from homeassistant.components.medcom_ble.const import DOMAIN +from homeassistant.const import CONF_ADDRESS +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import ( + MEDCOM_DEVICE_INFO, + MEDCOM_SERVICE_INFO, + UNKNOWN_SERVICE_INFO, + patch_async_ble_device_from_address, + patch_async_setup_entry, + patch_medcom_ble, +) + +from tests.common import MockConfigEntry + + +async def test_bluetooth_discovery(hass: HomeAssistant) -> None: + """Test discovery via bluetooth with a valid device.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=MEDCOM_SERVICE_INFO, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + assert result["description_placeholders"] == {"name": "InspectorBLE-D9A0"} + + with patch_async_ble_device_from_address(MEDCOM_SERVICE_INFO), patch_medcom_ble( + MedcomBleDevice( + manufacturer="International Medcom", + model="Inspector BLE", + model_raw="Inspector-BLE", + name="Inspector BLE", + identifier="a0d95a570b00", + ) + ): + with patch_async_setup_entry(): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"not": "empty"} + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "InspectorBLE-D9A0" + assert result["result"].unique_id == "a0:d9:5a:57:0b:00" + + +async def test_bluetooth_discovery_already_setup(hass: HomeAssistant) -> None: + """Test discovery via bluetooth with a valid device when already setup.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="a0:d9:5a:57:0b:00", + ) + entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=MEDCOM_DEVICE_INFO, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_user_setup(hass: HomeAssistant) -> None: + """Test the user initiated form.""" + with patch( + "homeassistant.components.medcom_ble.config_flow.async_discovered_service_info", + return_value=[MEDCOM_SERVICE_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"] is None + assert result["data_schema"] is not None + schema = result["data_schema"].schema + + assert schema.get(CONF_ADDRESS).container == { + "a0:d9:5a:57:0b:00": "InspectorBLE-D9A0" + } + + with patch_async_ble_device_from_address(MEDCOM_SERVICE_INFO), patch_medcom_ble( + MedcomBleDevice( + manufacturer="International Medcom", + model="Inspector BLE", + model_raw="Inspector-BLE", + name="Inspector BLE", + identifier="a0d95a570b00", + ) + ), patch( + "homeassistant.components.medcom_ble.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_ADDRESS: "a0:d9:5a:57:0b:00"} + ) + + await hass.async_block_till_done() + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "InspectorBLE-D9A0" + assert result["result"].unique_id == "a0:d9:5a:57:0b:00" + + +async def test_user_setup_no_device(hass: HomeAssistant) -> None: + """Test the user initiated form without any device detected.""" + with patch( + "homeassistant.components.medcom_ble.config_flow.async_discovered_service_info", + return_value=[], + ): + 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_setup_existing_and_unknown_device(hass: HomeAssistant) -> None: + """Test the user initiated form with existing devices and unknown ones.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="00:cc:cc:cc:cc:cc", + ) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.medcom_ble.config_flow.async_discovered_service_info", + return_value=[UNKNOWN_SERVICE_INFO, MEDCOM_SERVICE_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"] is None + assert result["data_schema"] is not None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_ADDRESS: "a0:d9:5a:57:0b:00"} + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_user_setup_unknown_device(hass: HomeAssistant) -> None: + """Test the user initiated form with only unknown devices.""" + with patch( + "homeassistant.components.medcom_ble.config_flow.async_discovered_service_info", + return_value=[UNKNOWN_SERVICE_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_setup_unknown_error(hass: HomeAssistant) -> None: + """Test the user initiated form with an unknown error.""" + with patch( + "homeassistant.components.medcom_ble.config_flow.async_discovered_service_info", + return_value=[MEDCOM_SERVICE_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"] is None + assert result["data_schema"] is not None + + with patch_async_ble_device_from_address(MEDCOM_SERVICE_INFO), patch_medcom_ble( + None, Exception() + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_ADDRESS: "a0:d9:5a:57:0b:00"} + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "unknown" + + +async def test_user_setup_unable_to_connect(hass: HomeAssistant) -> None: + """Test the user initiated form with a device that's failing connection.""" + with patch( + "homeassistant.components.medcom_ble.config_flow.async_discovered_service_info", + return_value=[MEDCOM_SERVICE_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"] is None + assert result["data_schema"] is not None + schema = result["data_schema"].schema + + assert schema.get(CONF_ADDRESS).container == { + "a0:d9:5a:57:0b:00": "InspectorBLE-D9A0" + } + + with patch_async_ble_device_from_address(MEDCOM_SERVICE_INFO), patch_medcom_ble( + side_effect=BleakError("An error") + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_ADDRESS: "a0:d9:5a:57:0b:00"} + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "cannot_connect" diff --git a/tests/components/media_extractor/__init__.py b/tests/components/media_extractor/__init__.py new file mode 100644 index 00000000000..d6faa60d3b4 --- /dev/null +++ b/tests/components/media_extractor/__init__.py @@ -0,0 +1,50 @@ +"""The tests for Media Extractor integration.""" +from typing import Any + +from tests.common import load_json_object_fixture +from tests.components.media_extractor.const import ( + AUDIO_QUERY, + NO_FORMATS_RESPONSE, + SOUNDCLOUD_TRACK, + YOUTUBE_EMPTY_PLAYLIST, + YOUTUBE_PLAYLIST, + YOUTUBE_VIDEO, +) + + +def _get_base_fixture(url: str) -> str: + return { + YOUTUBE_VIDEO: "youtube_1", + YOUTUBE_PLAYLIST: "youtube_playlist", + YOUTUBE_EMPTY_PLAYLIST: "youtube_empty_playlist", + SOUNDCLOUD_TRACK: "soundcloud", + NO_FORMATS_RESPONSE: "no_formats", + }[url] + + +def _get_query_fixture(query: str | None) -> str: + return {AUDIO_QUERY: "_bestaudio", "best": ""}.get(query, "") + + +class MockYoutubeDL: + """Mock object for YoutubeDL.""" + + _fixture = None + + def __init__(self, params: dict[str, Any]) -> None: + """Initialize mock object for YoutubeDL.""" + self.params = params + + def extract_info(self, url: str, *, process: bool = False) -> dict[str, Any]: + """Return info.""" + self._fixture = _get_base_fixture(url) + return load_json_object_fixture(f"media_extractor/{self._fixture}_info.json") + + def process_ie_result( + self, selected_media: dict[str, Any], *, download: bool = False + ) -> dict[str, Any]: + """Return result.""" + query_fixture = _get_query_fixture(self.params["format"]) + return load_json_object_fixture( + f"media_extractor/{self._fixture}_result{query_fixture}.json" + ) diff --git a/tests/components/media_extractor/conftest.py b/tests/components/media_extractor/conftest.py new file mode 100644 index 00000000000..8c8a6d6fb8d --- /dev/null +++ b/tests/components/media_extractor/conftest.py @@ -0,0 +1,54 @@ +"""The tests for Media Extractor integration.""" +from typing import Any +from unittest.mock import patch + +import pytest + +from homeassistant.components.media_extractor import DOMAIN +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.setup import async_setup_component + +from tests.common import async_mock_service +from tests.components.media_extractor import MockYoutubeDL +from tests.components.media_extractor.const import AUDIO_QUERY + + +@pytest.fixture(autouse=True) +async def setup_homeassistant(hass: HomeAssistant): + """Set up the homeassistant integration.""" + await async_setup_component(hass, "homeassistant", {}) + + +@pytest.fixture(autouse=True) +async def setup_media_player(hass: HomeAssistant) -> None: + """Set up the demo media player.""" + await async_setup_component( + hass, "media_player", {"media_player": {"platform": "demo"}} + ) + await hass.async_block_till_done() + + +@pytest.fixture +def calls(hass: HomeAssistant) -> list[ServiceCall]: + """Track calls to a mock service.""" + return async_mock_service(hass, "media_player", "play_media") + + +@pytest.fixture(name="mock_youtube_dl") +async def setup_mock_yt_dlp(hass: HomeAssistant) -> MockYoutubeDL: + """Mock YoutubeDL.""" + mock = MockYoutubeDL({}) + with patch("homeassistant.components.media_extractor.YoutubeDL", return_value=mock): + yield mock + + +@pytest.fixture(name="empty_media_extractor_config") +def empty_media_extractor_config() -> dict[str, Any]: + """Return base media extractor config.""" + return {DOMAIN: {}} + + +@pytest.fixture(name="audio_media_extractor_config") +def audio_media_extractor_config() -> dict[str, Any]: + """Media extractor config for audio.""" + return {DOMAIN: {"default_query": AUDIO_QUERY}} diff --git a/tests/components/media_extractor/const.py b/tests/components/media_extractor/const.py new file mode 100644 index 00000000000..ce309708823 --- /dev/null +++ b/tests/components/media_extractor/const.py @@ -0,0 +1,17 @@ +"""The tests for Media Extractor integration.""" + +AUDIO_QUERY = "bestaudio[ext=m4a]/bestaudio[ext=mp3]/bestaudio" + +YOUTUBE_VIDEO = "https://www.youtube.com/watch?v=dQw4w9WgXcQ" +YOUTUBE_PLAYLIST = ( + "https://www.youtube.com/playlist?list=PLZ4DbyIWUwCq4V8bIEa8jm2ozHZVuREJP" +) +YOUTUBE_EMPTY_PLAYLIST = ( + "https://www.youtube.com/playlist?list=PLZ4DbyIWUwCq4V8bIEa8jm2ozHZVuREJO" +) + +SOUNDCLOUD_TRACK = "https://soundcloud.com/bruttoband/brutto-11" + +# The ytdlp code indicates formats can be none. +# This acts as temporary fixtures until a real situation is found. +NO_FORMATS_RESPONSE = "https://test.com/abc" diff --git a/tests/components/media_extractor/fixtures/no_formats_info.json b/tests/components/media_extractor/fixtures/no_formats_info.json new file mode 100644 index 00000000000..bc475d16102 --- /dev/null +++ b/tests/components/media_extractor/fixtures/no_formats_info.json @@ -0,0 +1,85 @@ +{ + "id": "223644255", + "uploader": "BRUTTOBAND", + "uploader_id": "111488150", + "uploader_url": "https://soundcloud.com/bruttoband", + "timestamp": 1442140228, + "title": "BRUTTO - \u0420\u043e\u0434\u043d\u044b \u043a\u0440\u0430\u0439", + "description": "", + "thumbnails": [ + { + "id": "mini", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-mini.jpg", + "width": 16, + "height": 16 + }, + { + "id": "tiny", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-tiny.jpg", + "width": 20, + "height": 20 + }, + { + "id": "small", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-small.jpg", + "width": 32, + "height": 32 + }, + { + "id": "badge", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-badge.jpg", + "width": 47, + "height": 47 + }, + { + "id": "t67x67", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-t67x67.jpg", + "width": 67, + "height": 67 + }, + { + "id": "large", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-large.jpg", + "width": 100, + "height": 100 + }, + { + "id": "t300x300", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-t300x300.jpg", + "width": 300, + "height": 300 + }, + { + "id": "crop", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-crop.jpg", + "width": 400, + "height": 400 + }, + { + "id": "t500x500", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-t500x500.jpg", + "width": 500, + "height": 500 + }, + { + "id": "original", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-original.jpg", + "preference": 10 + } + ], + "duration": 229.089, + "webpage_url": "https://soundcloud.com/bruttoband/brutto-11", + "license": "all-rights-reserved", + "view_count": 290864, + "like_count": 3342, + "comment_count": 14, + "repost_count": 60, + "genre": "Brutto", + "original_url": "https://soundcloud.com/bruttoband/brutto-11", + "webpage_url_basename": "brutto-11", + "webpage_url_domain": "soundcloud.com", + "extractor": "soundcloud", + "extractor_key": "Soundcloud", + "heatmap": [], + "automatic_captions": {} +} diff --git a/tests/components/media_extractor/fixtures/no_formats_result.json b/tests/components/media_extractor/fixtures/no_formats_result.json new file mode 100644 index 00000000000..744bf76f93c --- /dev/null +++ b/tests/components/media_extractor/fixtures/no_formats_result.json @@ -0,0 +1,124 @@ +{ + "id": "223644255", + "uploader": "BRUTTOBAND", + "uploader_id": "111488150", + "uploader_url": "https://soundcloud.com/bruttoband", + "timestamp": 1442140228, + "title": "BRUTTO - \u0420\u043e\u0434\u043d\u044b \u043a\u0440\u0430\u0439", + "description": "", + "thumbnails": [ + { + "id": "mini", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-mini.jpg", + "width": 16, + "height": 16, + "resolution": "16x16" + }, + { + "id": "tiny", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-tiny.jpg", + "width": 20, + "height": 20, + "resolution": "20x20" + }, + { + "id": "small", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-small.jpg", + "width": 32, + "height": 32, + "resolution": "32x32" + }, + { + "id": "badge", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-badge.jpg", + "width": 47, + "height": 47, + "resolution": "47x47" + }, + { + "id": "t67x67", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-t67x67.jpg", + "width": 67, + "height": 67, + "resolution": "67x67" + }, + { + "id": "large", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-large.jpg", + "width": 100, + "height": 100, + "resolution": "100x100" + }, + { + "id": "t300x300", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-t300x300.jpg", + "width": 300, + "height": 300, + "resolution": "300x300" + }, + { + "id": "crop", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-crop.jpg", + "width": 400, + "height": 400, + "resolution": "400x400" + }, + { + "id": "t500x500", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-t500x500.jpg", + "width": 500, + "height": 500, + "resolution": "500x500" + }, + { + "id": "original", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-original.jpg", + "preference": 10 + } + ], + "duration": 229.089, + "webpage_url": "https://soundcloud.com/bruttoband/brutto-11", + "license": "all-rights-reserved", + "view_count": 290864, + "like_count": 3342, + "comment_count": 14, + "repost_count": 60, + "genre": "Brutto", + "original_url": "https://soundcloud.com/bruttoband/brutto-11", + "webpage_url_basename": "brutto-11", + "webpage_url_domain": "soundcloud.com", + "extractor": "soundcloud", + "extractor_key": "Soundcloud", + "heatmap": [], + "automatic_captions": {}, + "playlist": null, + "playlist_index": null, + "thumbnail": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-original.jpg", + "display_id": "223644255", + "fulltitle": "BRUTTO - \u0420\u043e\u0434\u043d\u044b \u043a\u0440\u0430\u0439", + "duration_string": "3:49", + "upload_date": "20150913", + "requested_subtitles": null, + "_has_drm": null, + "epoch": 1694798244, + "url": "https://cf-media.sndcdn.com/50remGX1OqRY.128.mp3?Policy=eyJTdGF0ZW1lbnQiOlt7IlJlc291cmNlIjoiKjovL2NmLW1lZGlhLnNuZGNkbi5jb20vNTByZW1HWDFPcVJZLjEyOC5tcDMqIiwiQ29uZGl0aW9uIjp7IkRhdGVMZXNzVGhhbiI6eyJBV1M6RXBvY2hUaW1lIjoxNjk0Nzk4NTkzfX19XX0_&Signature=flALJvEBnzS0ZOOhf0-07Ap~NURw2Gn2OqkeKKTTMX5HRGJw9eXFay79tcC4GsMMXWUgWoCx-n3yelpyilE2MOEIufBNUbjqRfMSJaX5YhYxjQdoDYuiU~gqBzJyPw9pKzr6P8~5HNKL3Idr0CNhUzdV6FQLaUPKMMibq9ghV833mUmdyvdk1~GZBc8MOg9GrTdcigGgpPzd-vrIMICMvFzFnwBOeOotxX2Vfqf9~wVekBKGlvB9A~7TlZ71lv9Fl9u4m8rse9E-mByweVc1M784ehJV3~tRPjuF~FXXWKP8x0nGJmoq7RAnG7iFIt~fQFmsfOq2o~PG7dHMRPh7hw__&Key-Pair-Id=APKAI6TU7MMXM5DG6EPQ", + "ext": "mp3", + "abr": 128, + "format_id": "http_mp3_128", + "protocol": "http", + "preference": null, + "vcodec": "none", + "resolution": "audio only", + "aspect_ratio": null, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.101 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "mp3", + "video_ext": "none", + "vbr": 0, + "tbr": 128, + "format": "http_mp3_128 - audio only" +} diff --git a/tests/components/media_extractor/fixtures/no_formats_result_bestaudio.json b/tests/components/media_extractor/fixtures/no_formats_result_bestaudio.json new file mode 100644 index 00000000000..664c61e96ae --- /dev/null +++ b/tests/components/media_extractor/fixtures/no_formats_result_bestaudio.json @@ -0,0 +1,124 @@ +{ + "id": "223644255", + "uploader": "BRUTTOBAND", + "uploader_id": "111488150", + "uploader_url": "https://soundcloud.com/bruttoband", + "timestamp": 1442140228, + "title": "BRUTTO - \u0420\u043e\u0434\u043d\u044b \u043a\u0440\u0430\u0439", + "description": "", + "thumbnails": [ + { + "id": "mini", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-mini.jpg", + "width": 16, + "height": 16, + "resolution": "16x16" + }, + { + "id": "tiny", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-tiny.jpg", + "width": 20, + "height": 20, + "resolution": "20x20" + }, + { + "id": "small", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-small.jpg", + "width": 32, + "height": 32, + "resolution": "32x32" + }, + { + "id": "badge", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-badge.jpg", + "width": 47, + "height": 47, + "resolution": "47x47" + }, + { + "id": "t67x67", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-t67x67.jpg", + "width": 67, + "height": 67, + "resolution": "67x67" + }, + { + "id": "large", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-large.jpg", + "width": 100, + "height": 100, + "resolution": "100x100" + }, + { + "id": "t300x300", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-t300x300.jpg", + "width": 300, + "height": 300, + "resolution": "300x300" + }, + { + "id": "crop", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-crop.jpg", + "width": 400, + "height": 400, + "resolution": "400x400" + }, + { + "id": "t500x500", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-t500x500.jpg", + "width": 500, + "height": 500, + "resolution": "500x500" + }, + { + "id": "original", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-original.jpg", + "preference": 10 + } + ], + "duration": 229.089, + "webpage_url": "https://soundcloud.com/bruttoband/brutto-11", + "license": "all-rights-reserved", + "view_count": 290870, + "like_count": 3342, + "comment_count": 14, + "repost_count": 60, + "genre": "Brutto", + "original_url": "https://soundcloud.com/bruttoband/brutto-11", + "webpage_url_basename": "brutto-11", + "webpage_url_domain": "soundcloud.com", + "extractor": "soundcloud", + "extractor_key": "Soundcloud", + "heatmap": [], + "automatic_captions": {}, + "playlist": null, + "playlist_index": null, + "thumbnail": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-original.jpg", + "display_id": "223644255", + "fulltitle": "BRUTTO - \u0420\u043e\u0434\u043d\u044b \u043a\u0440\u0430\u0439", + "duration_string": "3:49", + "upload_date": "20150913", + "requested_subtitles": null, + "_has_drm": null, + "epoch": 1694798829, + "url": "https://cf-media.sndcdn.com/50remGX1OqRY.128.mp3?Policy=eyJTdGF0ZW1lbnQiOlt7IlJlc291cmNlIjoiKjovL2NmLW1lZGlhLnNuZGNkbi5jb20vNTByZW1HWDFPcVJZLjEyOC5tcDMqIiwiQ29uZGl0aW9uIjp7IkRhdGVMZXNzVGhhbiI6eyJBV1M6RXBvY2hUaW1lIjoxNjk0Nzk5MTc5fX19XX0_&Signature=JtF8BXxTCElhjCrhnSAq3W6z960VmdVXx7BPhQvI0MCxr~J43JFGO8CVw9-VBM2oEf14mqWo63-C0FO29DvUuBZnmLD3dhDfryVfWJsrix7voimoRDaNFE~3zntDbg7O2S8uWYyZK8OZC9anzwokvjH7jbmviWqK4~2IM9dwgejGgzrQU1aadV2Yro7NJZnF7SD~7tVjkM-hBg~X5zDYVxmGrdzN3tFoLwRmUch6RNDL~1DcWBk0AveBKQFAdBrFBjDDUeIyDz9Idhw2aG9~fjfckcf95KwqrVQxz1N5XEzfNDDo8xkUgDt0eb9dtXdwxLJ0swC6e5VLS8bsH91GMg__&Key-Pair-Id=APKAI6TU7MMXM5DG6EPQ", + "ext": "mp3", + "abr": 128, + "format_id": "http_mp3_128", + "protocol": "http", + "preference": null, + "vcodec": "none", + "resolution": "audio only", + "aspect_ratio": null, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/95.0.4638.69 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "mp3", + "video_ext": "none", + "vbr": 0, + "tbr": 128, + "format": "http_mp3_128 - audio only" +} diff --git a/tests/components/media_extractor/fixtures/soundcloud_info.json b/tests/components/media_extractor/fixtures/soundcloud_info.json new file mode 100644 index 00000000000..676ef8edcf2 --- /dev/null +++ b/tests/components/media_extractor/fixtures/soundcloud_info.json @@ -0,0 +1,114 @@ +{ + "id": "223644255", + "uploader": "BRUTTOBAND", + "uploader_id": "111488150", + "uploader_url": "https://soundcloud.com/bruttoband", + "timestamp": 1442140228, + "title": "BRUTTO - \u0420\u043e\u0434\u043d\u044b \u043a\u0440\u0430\u0439", + "description": "", + "thumbnails": [ + { + "id": "mini", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-mini.jpg", + "width": 16, + "height": 16 + }, + { + "id": "tiny", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-tiny.jpg", + "width": 20, + "height": 20 + }, + { + "id": "small", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-small.jpg", + "width": 32, + "height": 32 + }, + { + "id": "badge", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-badge.jpg", + "width": 47, + "height": 47 + }, + { + "id": "t67x67", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-t67x67.jpg", + "width": 67, + "height": 67 + }, + { + "id": "large", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-large.jpg", + "width": 100, + "height": 100 + }, + { + "id": "t300x300", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-t300x300.jpg", + "width": 300, + "height": 300 + }, + { + "id": "crop", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-crop.jpg", + "width": 400, + "height": 400 + }, + { + "id": "t500x500", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-t500x500.jpg", + "width": 500, + "height": 500 + }, + { + "id": "original", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-original.jpg", + "preference": 10 + } + ], + "duration": 229.089, + "webpage_url": "https://soundcloud.com/bruttoband/brutto-11", + "license": "all-rights-reserved", + "view_count": 290864, + "like_count": 3342, + "comment_count": 14, + "repost_count": 60, + "genre": "Brutto", + "formats": [ + { + "url": "https://cf-hls-media.sndcdn.com/playlist/50remGX1OqRY.128.mp3/playlist.m3u8?Policy=eyJTdGF0ZW1lbnQiOlt7IlJlc291cmNlIjoiKjovL2NmLWhscy1tZWRpYS5zbmRjZG4uY29tL3BsYXlsaXN0LzUwcmVtR1gxT3FSWS4xMjgubXAzL3BsYXlsaXN0Lm0zdTgqIiwiQ29uZGl0aW9uIjp7IkRhdGVMZXNzVGhhbiI6eyJBV1M6RXBvY2hUaW1lIjoxNjk0Nzk4NTkzfX19XX0_&Signature=Nz4jXIokS4VBJ3AB~qzud7B2lEiGOZsu~k3BAOw4MdaT3Vqpq2wFoN9Nj5adjhPziclvTCitiro7oAYgHx-T6sKoUkgXXaanrhpUnmtnSWKSGHMIcGRjZD5~WnN9jc3VXt7kC1-1UMR3eiCgsNs~~iZSdr0EOk-W6IIJZ-XdIHJFekpcf3tt56uyoyicFfgRndjfbB9qijp3w1JVbNrAWL0oOHjk-76zspjytDQkunxtcT1cVd5VC1FiLd1azwX9bWkCHsb4Kk2sE0RRhycN7FePoG1FQysuN8deZ17NYD0CVi6QaHYzoQKrARODt1J-o0xAZWTbiwSobWcyZVc2ug__&Key-Pair-Id=APKAI6TU7MMXM5DG6EPQ", + "ext": "mp3", + "abr": 128, + "format_id": "hls_mp3_128", + "protocol": "m3u8_native", + "preference": null, + "vcodec": "none" + }, + { + "url": "https://cf-media.sndcdn.com/50remGX1OqRY.128.mp3?Policy=eyJTdGF0ZW1lbnQiOlt7IlJlc291cmNlIjoiKjovL2NmLW1lZGlhLnNuZGNkbi5jb20vNTByZW1HWDFPcVJZLjEyOC5tcDMqIiwiQ29uZGl0aW9uIjp7IkRhdGVMZXNzVGhhbiI6eyJBV1M6RXBvY2hUaW1lIjoxNjk0Nzk4NTkzfX19XX0_&Signature=flALJvEBnzS0ZOOhf0-07Ap~NURw2Gn2OqkeKKTTMX5HRGJw9eXFay79tcC4GsMMXWUgWoCx-n3yelpyilE2MOEIufBNUbjqRfMSJaX5YhYxjQdoDYuiU~gqBzJyPw9pKzr6P8~5HNKL3Idr0CNhUzdV6FQLaUPKMMibq9ghV833mUmdyvdk1~GZBc8MOg9GrTdcigGgpPzd-vrIMICMvFzFnwBOeOotxX2Vfqf9~wVekBKGlvB9A~7TlZ71lv9Fl9u4m8rse9E-mByweVc1M784ehJV3~tRPjuF~FXXWKP8x0nGJmoq7RAnG7iFIt~fQFmsfOq2o~PG7dHMRPh7hw__&Key-Pair-Id=APKAI6TU7MMXM5DG6EPQ", + "ext": "mp3", + "abr": 128, + "format_id": "http_mp3_128", + "protocol": "http", + "preference": null, + "vcodec": "none" + }, + { + "url": "https://cf-hls-opus-media.sndcdn.com/playlist/50remGX1OqRY.64.opus/playlist.m3u8?Policy=eyJTdGF0ZW1lbnQiOlt7IlJlc291cmNlIjoiKjovL2NmLWhscy1vcHVzLW1lZGlhLnNuZGNkbi5jb20vcGxheWxpc3QvNTByZW1HWDFPcVJZLjY0Lm9wdXMvcGxheWxpc3QubTN1OCoiLCJDb25kaXRpb24iOnsiRGF0ZUxlc3NUaGFuIjp7IkFXUzpFcG9jaFRpbWUiOjE2OTQ3OTg1OTN9fX1dfQ__&Signature=R2kxbkBwFOP8olMikw3IMYlC5gqMY173VH07Rq9Aq1vDkGbQwZzMd2OocIFQlsIhHDacH7WKPWdAqMFzuSb4KpHo6hi7KouM3dxXY5QgzQPRtfACyIbR3Kka7DGSVScJaCejp1xy5YoqEIhr8N36iogBPELiZs1jDAHf99cJnMHFN8SCMrej2BSNMSbCAaUXN2TlyViMR3yiG-kGY-RIs8pHDg0QE-M1tPAAAc94GynFDhbexHqFl-QIFQ4RxG9Pu7ooXqEG~xV848fgOUPUYC3yjCDZ7KKkW5BexSPD-ebavodz6kNU62GdIeuNzY3g-wftNLSQgwaMkg3aWj3VMA__&Key-Pair-Id=APKAI6TU7MMXM5DG6EPQ", + "ext": "opus", + "abr": 64, + "format_id": "hls_opus_64", + "protocol": "m3u8_native", + "preference": null, + "vcodec": "none" + } + ], + "original_url": "https://soundcloud.com/bruttoband/brutto-11", + "webpage_url_basename": "brutto-11", + "webpage_url_domain": "soundcloud.com", + "extractor": "soundcloud", + "extractor_key": "Soundcloud", + "heatmap": [], + "automatic_captions": {} +} diff --git a/tests/components/media_extractor/fixtures/soundcloud_result.json b/tests/components/media_extractor/fixtures/soundcloud_result.json new file mode 100644 index 00000000000..733d5650736 --- /dev/null +++ b/tests/components/media_extractor/fixtures/soundcloud_result.json @@ -0,0 +1,192 @@ +{ + "id": "223644255", + "uploader": "BRUTTOBAND", + "uploader_id": "111488150", + "uploader_url": "https://soundcloud.com/bruttoband", + "timestamp": 1442140228, + "title": "BRUTTO - \u0420\u043e\u0434\u043d\u044b \u043a\u0440\u0430\u0439", + "description": "", + "thumbnails": [ + { + "id": "mini", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-mini.jpg", + "width": 16, + "height": 16, + "resolution": "16x16" + }, + { + "id": "tiny", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-tiny.jpg", + "width": 20, + "height": 20, + "resolution": "20x20" + }, + { + "id": "small", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-small.jpg", + "width": 32, + "height": 32, + "resolution": "32x32" + }, + { + "id": "badge", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-badge.jpg", + "width": 47, + "height": 47, + "resolution": "47x47" + }, + { + "id": "t67x67", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-t67x67.jpg", + "width": 67, + "height": 67, + "resolution": "67x67" + }, + { + "id": "large", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-large.jpg", + "width": 100, + "height": 100, + "resolution": "100x100" + }, + { + "id": "t300x300", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-t300x300.jpg", + "width": 300, + "height": 300, + "resolution": "300x300" + }, + { + "id": "crop", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-crop.jpg", + "width": 400, + "height": 400, + "resolution": "400x400" + }, + { + "id": "t500x500", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-t500x500.jpg", + "width": 500, + "height": 500, + "resolution": "500x500" + }, + { + "id": "original", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-original.jpg", + "preference": 10 + } + ], + "duration": 229.089, + "webpage_url": "https://soundcloud.com/bruttoband/brutto-11", + "license": "all-rights-reserved", + "view_count": 290864, + "like_count": 3342, + "comment_count": 14, + "repost_count": 60, + "genre": "Brutto", + "formats": [ + { + "url": "https://cf-hls-opus-media.sndcdn.com/playlist/50remGX1OqRY.64.opus/playlist.m3u8?Policy=eyJTdGF0ZW1lbnQiOlt7IlJlc291cmNlIjoiKjovL2NmLWhscy1vcHVzLW1lZGlhLnNuZGNkbi5jb20vcGxheWxpc3QvNTByZW1HWDFPcVJZLjY0Lm9wdXMvcGxheWxpc3QubTN1OCoiLCJDb25kaXRpb24iOnsiRGF0ZUxlc3NUaGFuIjp7IkFXUzpFcG9jaFRpbWUiOjE2OTQ3OTg1OTN9fX1dfQ__&Signature=R2kxbkBwFOP8olMikw3IMYlC5gqMY173VH07Rq9Aq1vDkGbQwZzMd2OocIFQlsIhHDacH7WKPWdAqMFzuSb4KpHo6hi7KouM3dxXY5QgzQPRtfACyIbR3Kka7DGSVScJaCejp1xy5YoqEIhr8N36iogBPELiZs1jDAHf99cJnMHFN8SCMrej2BSNMSbCAaUXN2TlyViMR3yiG-kGY-RIs8pHDg0QE-M1tPAAAc94GynFDhbexHqFl-QIFQ4RxG9Pu7ooXqEG~xV848fgOUPUYC3yjCDZ7KKkW5BexSPD-ebavodz6kNU62GdIeuNzY3g-wftNLSQgwaMkg3aWj3VMA__&Key-Pair-Id=APKAI6TU7MMXM5DG6EPQ", + "ext": "opus", + "abr": 64, + "format_id": "hls_opus_64", + "protocol": "m3u8_native", + "preference": null, + "vcodec": "none", + "resolution": "audio only", + "aspect_ratio": null, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.101 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "opus", + "video_ext": "none", + "vbr": 0, + "tbr": 64, + "format": "hls_opus_64 - audio only" + }, + { + "url": "https://cf-hls-media.sndcdn.com/playlist/50remGX1OqRY.128.mp3/playlist.m3u8?Policy=eyJTdGF0ZW1lbnQiOlt7IlJlc291cmNlIjoiKjovL2NmLWhscy1tZWRpYS5zbmRjZG4uY29tL3BsYXlsaXN0LzUwcmVtR1gxT3FSWS4xMjgubXAzL3BsYXlsaXN0Lm0zdTgqIiwiQ29uZGl0aW9uIjp7IkRhdGVMZXNzVGhhbiI6eyJBV1M6RXBvY2hUaW1lIjoxNjk0Nzk4NTkzfX19XX0_&Signature=Nz4jXIokS4VBJ3AB~qzud7B2lEiGOZsu~k3BAOw4MdaT3Vqpq2wFoN9Nj5adjhPziclvTCitiro7oAYgHx-T6sKoUkgXXaanrhpUnmtnSWKSGHMIcGRjZD5~WnN9jc3VXt7kC1-1UMR3eiCgsNs~~iZSdr0EOk-W6IIJZ-XdIHJFekpcf3tt56uyoyicFfgRndjfbB9qijp3w1JVbNrAWL0oOHjk-76zspjytDQkunxtcT1cVd5VC1FiLd1azwX9bWkCHsb4Kk2sE0RRhycN7FePoG1FQysuN8deZ17NYD0CVi6QaHYzoQKrARODt1J-o0xAZWTbiwSobWcyZVc2ug__&Key-Pair-Id=APKAI6TU7MMXM5DG6EPQ", + "ext": "mp3", + "abr": 128, + "format_id": "hls_mp3_128", + "protocol": "m3u8_native", + "preference": null, + "vcodec": "none", + "resolution": "audio only", + "aspect_ratio": null, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.101 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "mp3", + "video_ext": "none", + "vbr": 0, + "tbr": 128, + "format": "hls_mp3_128 - audio only" + }, + { + "url": "https://cf-media.sndcdn.com/50remGX1OqRY.128.mp3?Policy=eyJTdGF0ZW1lbnQiOlt7IlJlc291cmNlIjoiKjovL2NmLW1lZGlhLnNuZGNkbi5jb20vNTByZW1HWDFPcVJZLjEyOC5tcDMqIiwiQ29uZGl0aW9uIjp7IkRhdGVMZXNzVGhhbiI6eyJBV1M6RXBvY2hUaW1lIjoxNjk0Nzk4NTkzfX19XX0_&Signature=flALJvEBnzS0ZOOhf0-07Ap~NURw2Gn2OqkeKKTTMX5HRGJw9eXFay79tcC4GsMMXWUgWoCx-n3yelpyilE2MOEIufBNUbjqRfMSJaX5YhYxjQdoDYuiU~gqBzJyPw9pKzr6P8~5HNKL3Idr0CNhUzdV6FQLaUPKMMibq9ghV833mUmdyvdk1~GZBc8MOg9GrTdcigGgpPzd-vrIMICMvFzFnwBOeOotxX2Vfqf9~wVekBKGlvB9A~7TlZ71lv9Fl9u4m8rse9E-mByweVc1M784ehJV3~tRPjuF~FXXWKP8x0nGJmoq7RAnG7iFIt~fQFmsfOq2o~PG7dHMRPh7hw__&Key-Pair-Id=APKAI6TU7MMXM5DG6EPQ", + "ext": "mp3", + "abr": 128, + "format_id": "http_mp3_128", + "protocol": "http", + "preference": null, + "vcodec": "none", + "resolution": "audio only", + "aspect_ratio": null, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.101 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "mp3", + "video_ext": "none", + "vbr": 0, + "tbr": 128, + "format": "http_mp3_128 - audio only" + } + ], + "original_url": "https://soundcloud.com/bruttoband/brutto-11", + "webpage_url_basename": "brutto-11", + "webpage_url_domain": "soundcloud.com", + "extractor": "soundcloud", + "extractor_key": "Soundcloud", + "heatmap": [], + "automatic_captions": {}, + "playlist": null, + "playlist_index": null, + "thumbnail": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-original.jpg", + "display_id": "223644255", + "fulltitle": "BRUTTO - \u0420\u043e\u0434\u043d\u044b \u043a\u0440\u0430\u0439", + "duration_string": "3:49", + "upload_date": "20150913", + "requested_subtitles": null, + "_has_drm": null, + "epoch": 1694798244, + "url": "https://cf-media.sndcdn.com/50remGX1OqRY.128.mp3?Policy=eyJTdGF0ZW1lbnQiOlt7IlJlc291cmNlIjoiKjovL2NmLW1lZGlhLnNuZGNkbi5jb20vNTByZW1HWDFPcVJZLjEyOC5tcDMqIiwiQ29uZGl0aW9uIjp7IkRhdGVMZXNzVGhhbiI6eyJBV1M6RXBvY2hUaW1lIjoxNjk0Nzk4NTkzfX19XX0_&Signature=flALJvEBnzS0ZOOhf0-07Ap~NURw2Gn2OqkeKKTTMX5HRGJw9eXFay79tcC4GsMMXWUgWoCx-n3yelpyilE2MOEIufBNUbjqRfMSJaX5YhYxjQdoDYuiU~gqBzJyPw9pKzr6P8~5HNKL3Idr0CNhUzdV6FQLaUPKMMibq9ghV833mUmdyvdk1~GZBc8MOg9GrTdcigGgpPzd-vrIMICMvFzFnwBOeOotxX2Vfqf9~wVekBKGlvB9A~7TlZ71lv9Fl9u4m8rse9E-mByweVc1M784ehJV3~tRPjuF~FXXWKP8x0nGJmoq7RAnG7iFIt~fQFmsfOq2o~PG7dHMRPh7hw__&Key-Pair-Id=APKAI6TU7MMXM5DG6EPQ", + "ext": "mp3", + "abr": 128, + "format_id": "http_mp3_128", + "protocol": "http", + "preference": null, + "vcodec": "none", + "resolution": "audio only", + "aspect_ratio": null, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.101 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "mp3", + "video_ext": "none", + "vbr": 0, + "tbr": 128, + "format": "http_mp3_128 - audio only" +} diff --git a/tests/components/media_extractor/fixtures/soundcloud_result_bestaudio.json b/tests/components/media_extractor/fixtures/soundcloud_result_bestaudio.json new file mode 100644 index 00000000000..4f2611d25fe --- /dev/null +++ b/tests/components/media_extractor/fixtures/soundcloud_result_bestaudio.json @@ -0,0 +1,192 @@ +{ + "id": "223644255", + "uploader": "BRUTTOBAND", + "uploader_id": "111488150", + "uploader_url": "https://soundcloud.com/bruttoband", + "timestamp": 1442140228, + "title": "BRUTTO - \u0420\u043e\u0434\u043d\u044b \u043a\u0440\u0430\u0439", + "description": "", + "thumbnails": [ + { + "id": "mini", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-mini.jpg", + "width": 16, + "height": 16, + "resolution": "16x16" + }, + { + "id": "tiny", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-tiny.jpg", + "width": 20, + "height": 20, + "resolution": "20x20" + }, + { + "id": "small", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-small.jpg", + "width": 32, + "height": 32, + "resolution": "32x32" + }, + { + "id": "badge", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-badge.jpg", + "width": 47, + "height": 47, + "resolution": "47x47" + }, + { + "id": "t67x67", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-t67x67.jpg", + "width": 67, + "height": 67, + "resolution": "67x67" + }, + { + "id": "large", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-large.jpg", + "width": 100, + "height": 100, + "resolution": "100x100" + }, + { + "id": "t300x300", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-t300x300.jpg", + "width": 300, + "height": 300, + "resolution": "300x300" + }, + { + "id": "crop", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-crop.jpg", + "width": 400, + "height": 400, + "resolution": "400x400" + }, + { + "id": "t500x500", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-t500x500.jpg", + "width": 500, + "height": 500, + "resolution": "500x500" + }, + { + "id": "original", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-original.jpg", + "preference": 10 + } + ], + "duration": 229.089, + "webpage_url": "https://soundcloud.com/bruttoband/brutto-11", + "license": "all-rights-reserved", + "view_count": 290870, + "like_count": 3342, + "comment_count": 14, + "repost_count": 60, + "genre": "Brutto", + "formats": [ + { + "url": "https://cf-hls-opus-media.sndcdn.com/playlist/50remGX1OqRY.64.opus/playlist.m3u8?Policy=eyJTdGF0ZW1lbnQiOlt7IlJlc291cmNlIjoiKjovL2NmLWhscy1vcHVzLW1lZGlhLnNuZGNkbi5jb20vcGxheWxpc3QvNTByZW1HWDFPcVJZLjY0Lm9wdXMvcGxheWxpc3QubTN1OCoiLCJDb25kaXRpb24iOnsiRGF0ZUxlc3NUaGFuIjp7IkFXUzpFcG9jaFRpbWUiOjE2OTQ3OTkxNzl9fX1dfQ__&Signature=RwhiR-Mxl364C~ElpyNWLOwq1zkMdy8koxJB09jy6BxU0YAFlRQb4vB34s6gMN7ycK7ubC7kDOyJ5TAoXu8M4Jtxh8zkAmhy4RFwclsrquliRmszQBPyMXYTdsNa~JJCydEEUlSmxUCGxZZXtXWvKLDBkqcz5PAlFRQZFKnow3xJleM~Oy6sYkRvq6YH3G3sR4svUdU6V8582QpnLqB0BZp3xtcNaHFQQutpneIWzULhSKp65iGZIKL2d9xCB5PF4YUSQwXGfec6O~6G63HN~lGwq5HOWZm2jN87d4Q30QnETh3FThcf5~TomYcEzV1hKqBFneRs8jRhOkdExiCdWg__&Key-Pair-Id=APKAI6TU7MMXM5DG6EPQ", + "ext": "opus", + "abr": 64, + "format_id": "hls_opus_64", + "protocol": "m3u8_native", + "preference": null, + "vcodec": "none", + "resolution": "audio only", + "aspect_ratio": null, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/95.0.4638.69 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "opus", + "video_ext": "none", + "vbr": 0, + "tbr": 64, + "format": "hls_opus_64 - audio only" + }, + { + "url": "https://cf-hls-media.sndcdn.com/playlist/50remGX1OqRY.128.mp3/playlist.m3u8?Policy=eyJTdGF0ZW1lbnQiOlt7IlJlc291cmNlIjoiKjovL2NmLWhscy1tZWRpYS5zbmRjZG4uY29tL3BsYXlsaXN0LzUwcmVtR1gxT3FSWS4xMjgubXAzL3BsYXlsaXN0Lm0zdTgqIiwiQ29uZGl0aW9uIjp7IkRhdGVMZXNzVGhhbiI6eyJBV1M6RXBvY2hUaW1lIjoxNjk0Nzk5MTc5fX19XX0_&Signature=OZGdUNyVgOztaOWLoe8FPCDNtLrAmQK8nNfecpnMReiO3bsRPTL8bD7E1nVOfXMYPB4MD-lHDFtWM4nJenCmi6ctyHI-H48A9ELM2-bDbLuD2I6cgweJ5xUSVKFpS8CmWHIgAhVXycUYiWD9cqgf4-EsVNgJr41vIFGmw1RJZsKcC3zC3xxg6enb4fJZ0Q~vwNjUoMb3gBaIsEC-Hoy5LRZC5kp1ro8kLKH-Yi~9i2nYkqIZkDqpt7PrIKP379MYexxsmXWOUeL0iRXZ93qM10YHxOS09d22o~kVaUQx0MDRZgrm8ku7gV~tmAN77JmZ9cnDAuKdh6vHwzVVOTdqCg__&Key-Pair-Id=APKAI6TU7MMXM5DG6EPQ", + "ext": "mp3", + "abr": 128, + "format_id": "hls_mp3_128", + "protocol": "m3u8_native", + "preference": null, + "vcodec": "none", + "resolution": "audio only", + "aspect_ratio": null, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/95.0.4638.69 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "mp3", + "video_ext": "none", + "vbr": 0, + "tbr": 128, + "format": "hls_mp3_128 - audio only" + }, + { + "url": "https://cf-media.sndcdn.com/50remGX1OqRY.128.mp3?Policy=eyJTdGF0ZW1lbnQiOlt7IlJlc291cmNlIjoiKjovL2NmLW1lZGlhLnNuZGNkbi5jb20vNTByZW1HWDFPcVJZLjEyOC5tcDMqIiwiQ29uZGl0aW9uIjp7IkRhdGVMZXNzVGhhbiI6eyJBV1M6RXBvY2hUaW1lIjoxNjk0Nzk5MTc5fX19XX0_&Signature=JtF8BXxTCElhjCrhnSAq3W6z960VmdVXx7BPhQvI0MCxr~J43JFGO8CVw9-VBM2oEf14mqWo63-C0FO29DvUuBZnmLD3dhDfryVfWJsrix7voimoRDaNFE~3zntDbg7O2S8uWYyZK8OZC9anzwokvjH7jbmviWqK4~2IM9dwgejGgzrQU1aadV2Yro7NJZnF7SD~7tVjkM-hBg~X5zDYVxmGrdzN3tFoLwRmUch6RNDL~1DcWBk0AveBKQFAdBrFBjDDUeIyDz9Idhw2aG9~fjfckcf95KwqrVQxz1N5XEzfNDDo8xkUgDt0eb9dtXdwxLJ0swC6e5VLS8bsH91GMg__&Key-Pair-Id=APKAI6TU7MMXM5DG6EPQ", + "ext": "mp3", + "abr": 128, + "format_id": "http_mp3_128", + "protocol": "http", + "preference": null, + "vcodec": "none", + "resolution": "audio only", + "aspect_ratio": null, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/95.0.4638.69 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "mp3", + "video_ext": "none", + "vbr": 0, + "tbr": 128, + "format": "http_mp3_128 - audio only" + } + ], + "original_url": "https://soundcloud.com/bruttoband/brutto-11", + "webpage_url_basename": "brutto-11", + "webpage_url_domain": "soundcloud.com", + "extractor": "soundcloud", + "extractor_key": "Soundcloud", + "heatmap": [], + "automatic_captions": {}, + "playlist": null, + "playlist_index": null, + "thumbnail": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-original.jpg", + "display_id": "223644255", + "fulltitle": "BRUTTO - \u0420\u043e\u0434\u043d\u044b \u043a\u0440\u0430\u0439", + "duration_string": "3:49", + "upload_date": "20150913", + "requested_subtitles": null, + "_has_drm": null, + "epoch": 1694798829, + "url": "https://cf-media.sndcdn.com/50remGX1OqRY.128.mp3?Policy=eyJTdGF0ZW1lbnQiOlt7IlJlc291cmNlIjoiKjovL2NmLW1lZGlhLnNuZGNkbi5jb20vNTByZW1HWDFPcVJZLjEyOC5tcDMqIiwiQ29uZGl0aW9uIjp7IkRhdGVMZXNzVGhhbiI6eyJBV1M6RXBvY2hUaW1lIjoxNjk0Nzk5MTc5fX19XX0_&Signature=JtF8BXxTCElhjCrhnSAq3W6z960VmdVXx7BPhQvI0MCxr~J43JFGO8CVw9-VBM2oEf14mqWo63-C0FO29DvUuBZnmLD3dhDfryVfWJsrix7voimoRDaNFE~3zntDbg7O2S8uWYyZK8OZC9anzwokvjH7jbmviWqK4~2IM9dwgejGgzrQU1aadV2Yro7NJZnF7SD~7tVjkM-hBg~X5zDYVxmGrdzN3tFoLwRmUch6RNDL~1DcWBk0AveBKQFAdBrFBjDDUeIyDz9Idhw2aG9~fjfckcf95KwqrVQxz1N5XEzfNDDo8xkUgDt0eb9dtXdwxLJ0swC6e5VLS8bsH91GMg__&Key-Pair-Id=APKAI6TU7MMXM5DG6EPQ", + "ext": "mp3", + "abr": 128, + "format_id": "http_mp3_128", + "protocol": "http", + "preference": null, + "vcodec": "none", + "resolution": "audio only", + "aspect_ratio": null, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/95.0.4638.69 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "mp3", + "video_ext": "none", + "vbr": 0, + "tbr": 128, + "format": "http_mp3_128 - audio only" +} diff --git a/tests/components/media_extractor/fixtures/youtube_1_info.json b/tests/components/media_extractor/fixtures/youtube_1_info.json new file mode 100644 index 00000000000..2113c70bf93 --- /dev/null +++ b/tests/components/media_extractor/fixtures/youtube_1_info.json @@ -0,0 +1,1430 @@ +{ + "id": "dQw4w9WgXcQ", + "title": "Rick Astley - Never Gonna Give You Up (Official Music Video)", + "formats": [ + { + "asr": null, + "filesize": 80166145, + "format_id": "137", + "format_note": "1080p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 1080, + "quality": 9.0, + "has_drm": false, + "tbr": 3024.566, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805362&ei=ElkEZZexM5LZx_AP96OLyAo&ip=45.93.75.130&id=o-AFu4BNKMyT_lNrK_5ZvyErJEiYG7Ph8XYcCU7y4k3_R0&itag=137&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=80166145&dur=212.040&lmt=1694045208995966&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246&beids=24350017&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIgMOPEGU85HZaKo3xJCOnGrSM_eYum5wQ2JWv_sIqPT-QCIQD9iSTPNKGEuSHLmMyeEJTRd10XJV5FCxSp6OkKsy6Aag%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIhAOmpt4JjYheZ74B5RrWD2NtuaXZ0Cc91wENOUC5FPpMaAiBOtouFd-bPf0fUCivtANHwRrUQOag_yM5njqHC6WqWDw%3D%3D", + "width": 1920, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "avc1.640028", + "acodec": "none", + "dynamic_range": null, + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": null, + "filesize": 40874930, + "format_id": "248", + "format_note": "1080p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 1080, + "quality": 9.0, + "has_drm": false, + "tbr": 1542.159, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805362&ei=ElkEZZexM5LZx_AP96OLyAo&ip=45.93.75.130&id=o-AFu4BNKMyT_lNrK_5ZvyErJEiYG7Ph8XYcCU7y4k3_R0&itag=248&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&vprv=1&svpuc=1&mime=video%2Fwebm&gir=yes&clen=40874930&dur=212.040&lmt=1694044655610179&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246&beids=24350017&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRAIgFrJLcTooT0PMPfSpXzGL9kcjwMeBinMoDzZUYMu1-zICIBq1ZEX1kBebvc9X3WJVXLTkGQjPirzd0haLxQBu3RpP&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIhAOmpt4JjYheZ74B5RrWD2NtuaXZ0Cc91wENOUC5FPpMaAiBOtouFd-bPf0fUCivtANHwRrUQOag_yM5njqHC6WqWDw%3D%3D", + "width": 1920, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "vp09.00.40.08", + "acodec": "none", + "dynamic_range": null, + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": null, + "filesize": 16780212, + "format_id": "136", + "format_note": "720p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 720, + "quality": 8.0, + "has_drm": false, + "tbr": 633.096, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805362&ei=ElkEZZexM5LZx_AP96OLyAo&ip=45.93.75.130&id=o-AFu4BNKMyT_lNrK_5ZvyErJEiYG7Ph8XYcCU7y4k3_R0&itag=136&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=16780212&dur=212.040&lmt=1694045071129751&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246&beids=24350017&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhAORMEnAszaneCnCkbv7EYGZCGkwdFPz9AVtiaenajNjOAiEAu7pr7y9BmQj4TKgJweT6Iamv2GFyiH9tGrxdBHLySZ4%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIhAOmpt4JjYheZ74B5RrWD2NtuaXZ0Cc91wENOUC5FPpMaAiBOtouFd-bPf0fUCivtANHwRrUQOag_yM5njqHC6WqWDw%3D%3D", + "width": 1280, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "avc1.4D401F", + "acodec": "none", + "dynamic_range": null, + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": null, + "filesize": 15359727, + "format_id": "247", + "format_note": "720p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 720, + "quality": 8.0, + "has_drm": false, + "tbr": 579.502, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805362&ei=ElkEZZexM5LZx_AP96OLyAo&ip=45.93.75.130&id=o-AFu4BNKMyT_lNrK_5ZvyErJEiYG7Ph8XYcCU7y4k3_R0&itag=247&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&vprv=1&svpuc=1&mime=video%2Fwebm&gir=yes&clen=15359727&dur=212.040&lmt=1694043486219683&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246&beids=24350017&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIhAPqu9CfJRKuiXZ-9aQM7HGlnOb7KffCvaBihabKlWCwQAiBtvX1-H3yowRj7nXXF1Mh1Kq4Xlad0rSE3iloo-GIALQ%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIhAOmpt4JjYheZ74B5RrWD2NtuaXZ0Cc91wENOUC5FPpMaAiBOtouFd-bPf0fUCivtANHwRrUQOag_yM5njqHC6WqWDw%3D%3D", + "width": 1280, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "vp09.00.31.08", + "acodec": "none", + "dynamic_range": null, + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": null, + "filesize": 8683274, + "format_id": "135", + "format_note": "480p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 480, + "quality": 7.0, + "has_drm": false, + "tbr": 327.608, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805362&ei=ElkEZZexM5LZx_AP96OLyAo&ip=45.93.75.130&id=o-AFu4BNKMyT_lNrK_5ZvyErJEiYG7Ph8XYcCU7y4k3_R0&itag=135&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=8683274&dur=212.040&lmt=1694045045723793&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246&beids=24350017&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRAIgDIYxFGErWArarngsKDSmFORBXj5VuIZP6M25rjY03noCIGtdROP2F5ackkfTy1jzfWSOP5miZyVR7Ha7Nye-1p2y&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIhAOmpt4JjYheZ74B5RrWD2NtuaXZ0Cc91wENOUC5FPpMaAiBOtouFd-bPf0fUCivtANHwRrUQOag_yM5njqHC6WqWDw%3D%3D", + "width": 854, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "avc1.4D401E", + "acodec": "none", + "dynamic_range": null, + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": null, + "filesize": 10927666, + "format_id": "244", + "format_note": "480p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 480, + "quality": 7.0, + "has_drm": false, + "tbr": 412.286, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805362&ei=ElkEZZexM5LZx_AP96OLyAo&ip=45.93.75.130&id=o-AFu4BNKMyT_lNrK_5ZvyErJEiYG7Ph8XYcCU7y4k3_R0&itag=244&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&vprv=1&svpuc=1&mime=video%2Fwebm&gir=yes&clen=10927666&dur=212.040&lmt=1694043369037289&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246&beids=24350017&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhAKmTgCM6pbpWigpYWyAuJowoktDFAZ2oDv88cyWXX6eGAiEAkCdcjO3DqmoE6cgwGNAjM-QzC03BO3eb5CNlyADCmSo%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIhAOmpt4JjYheZ74B5RrWD2NtuaXZ0Cc91wENOUC5FPpMaAiBOtouFd-bPf0fUCivtANHwRrUQOag_yM5njqHC6WqWDw%3D%3D", + "width": 854, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "vp09.00.30.08", + "acodec": "none", + "dynamic_range": null, + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": null, + "filesize": 5678772, + "format_id": "134", + "format_note": "360p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 360, + "quality": 6.0, + "has_drm": false, + "tbr": 214.252, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805362&ei=ElkEZZexM5LZx_AP96OLyAo&ip=45.93.75.130&id=o-AFu4BNKMyT_lNrK_5ZvyErJEiYG7Ph8XYcCU7y4k3_R0&itag=134&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=5678772&dur=212.040&lmt=1694045013473544&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246&beids=24350017&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIhAMKDYtO5SAdolqwixSH_eyzJ8ooPULtSs-zuBzJmGHsjAiAnG_Ywr9JID7K3Ocn2x0TNTTK0RhSDs6ZQOfcpuFiH4w%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIhAOmpt4JjYheZ74B5RrWD2NtuaXZ0Cc91wENOUC5FPpMaAiBOtouFd-bPf0fUCivtANHwRrUQOag_yM5njqHC6WqWDw%3D%3D", + "width": 640, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "avc1.4D401E", + "acodec": "none", + "dynamic_range": null, + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": null, + "filesize": 6902164, + "format_id": "243", + "format_note": "360p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 360, + "quality": 6.0, + "has_drm": false, + "tbr": 260.409, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805362&ei=ElkEZZexM5LZx_AP96OLyAo&ip=45.93.75.130&id=o-AFu4BNKMyT_lNrK_5ZvyErJEiYG7Ph8XYcCU7y4k3_R0&itag=243&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&vprv=1&svpuc=1&mime=video%2Fwebm&gir=yes&clen=6902164&dur=212.040&lmt=1694043349554753&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246&beids=24350017&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhAItvMxp96X6C1yfrVoGAZqmfo3bTCrVFKdChCEWVXV-IAiEAsDW5gJjGupba7Z-Ww2HyoOIn7kNTlsSA6DF0hp9WWto%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIhAOmpt4JjYheZ74B5RrWD2NtuaXZ0Cc91wENOUC5FPpMaAiBOtouFd-bPf0fUCivtANHwRrUQOag_yM5njqHC6WqWDw%3D%3D", + "width": 640, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "vp09.00.21.08", + "acodec": "none", + "dynamic_range": null, + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": null, + "filesize": 3019976, + "format_id": "133", + "format_note": "240p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 240, + "quality": 5.0, + "has_drm": false, + "tbr": 113.939, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805362&ei=ElkEZZexM5LZx_AP96OLyAo&ip=45.93.75.130&id=o-AFu4BNKMyT_lNrK_5ZvyErJEiYG7Ph8XYcCU7y4k3_R0&itag=133&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=3019976&dur=212.040&lmt=1694045014258984&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246&beids=24350017&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIgexQ8jEkRYdHBLhxX3FIVdj65lF_lGYGmyeX7GVOpoqACIQDzUOtwajoE2kMHTO07xg2XZCM1SKbnd3fEvdEFn0TsFQ%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIhAOmpt4JjYheZ74B5RrWD2NtuaXZ0Cc91wENOUC5FPpMaAiBOtouFd-bPf0fUCivtANHwRrUQOag_yM5njqHC6WqWDw%3D%3D", + "width": 426, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "avc1.4D4015", + "acodec": "none", + "dynamic_range": null, + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": null, + "filesize": 4021173, + "format_id": "242", + "format_note": "240p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 240, + "quality": 5.0, + "has_drm": false, + "tbr": 151.713, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805362&ei=ElkEZZexM5LZx_AP96OLyAo&ip=45.93.75.130&id=o-AFu4BNKMyT_lNrK_5ZvyErJEiYG7Ph8XYcCU7y4k3_R0&itag=242&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&vprv=1&svpuc=1&mime=video%2Fwebm&gir=yes&clen=4021173&dur=212.040&lmt=1694043379783601&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246&beids=24350017&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRAIgG1yThWV_AtuBmQUcjdVuVyTRxyTMtieIJSF0RyDqPakCICerLreGPgSn_5VDDGOTV7vr8FZhXNa0PEvQ08FM2VXe&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIhAOmpt4JjYheZ74B5RrWD2NtuaXZ0Cc91wENOUC5FPpMaAiBOtouFd-bPf0fUCivtANHwRrUQOag_yM5njqHC6WqWDw%3D%3D", + "width": 426, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "vp09.00.20.08", + "acodec": "none", + "dynamic_range": null, + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": null, + "filesize": 1863601, + "format_id": "160", + "format_note": "144p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 144, + "quality": 0.0, + "has_drm": false, + "tbr": 70.311, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805362&ei=ElkEZZexM5LZx_AP96OLyAo&ip=45.93.75.130&id=o-AFu4BNKMyT_lNrK_5ZvyErJEiYG7Ph8XYcCU7y4k3_R0&itag=160&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=1863601&dur=212.040&lmt=1694045032286738&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246&beids=24350017&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhAKbBRlNNEf_VxL4bIwHKGABHTgFydGk3cmV02QYjnxl_AiEAxhb6sQymJuRboWP9y4qjEFJTb7gY3WuUbbROgX9Bzjg%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIhAOmpt4JjYheZ74B5RrWD2NtuaXZ0Cc91wENOUC5FPpMaAiBOtouFd-bPf0fUCivtANHwRrUQOag_yM5njqHC6WqWDw%3D%3D", + "width": 256, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "avc1.4D400C", + "acodec": "none", + "dynamic_range": null, + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": null, + "filesize": 2404581, + "format_id": "278", + "format_note": "144p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 144, + "quality": 0.0, + "has_drm": false, + "tbr": 90.721, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805362&ei=ElkEZZexM5LZx_AP96OLyAo&ip=45.93.75.130&id=o-AFu4BNKMyT_lNrK_5ZvyErJEiYG7Ph8XYcCU7y4k3_R0&itag=278&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&vprv=1&svpuc=1&mime=video%2Fwebm&gir=yes&clen=2404581&dur=212.040&lmt=1694043382822868&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246&beids=24350017&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRAIgTJitjV0o5vvx0y-66FPcfksjQTVxjfYtdvuVPiwkIDsCIHXOAjhGi9WB8_Wg_6_UUhdfDTr8Bp5z4TrwipILaFAe&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIhAOmpt4JjYheZ74B5RrWD2NtuaXZ0Cc91wENOUC5FPpMaAiBOtouFd-bPf0fUCivtANHwRrUQOag_yM5njqHC6WqWDw%3D%3D", + "width": 256, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "vp09.00.11.08", + "acodec": "none", + "dynamic_range": null, + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": 22050, + "filesize": 1294944, + "format_id": "139", + "format_note": "low", + "source_preference": -1, + "fps": null, + "audio_channels": 2, + "height": null, + "quality": 2.0, + "has_drm": false, + "tbr": 48.823, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805362&ei=ElkEZZexM5LZx_AP96OLyAo&ip=45.93.75.130&id=o-AFu4BNKMyT_lNrK_5ZvyErJEiYG7Ph8XYcCU7y4k3_R0&itag=139&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&vprv=1&svpuc=1&mime=audio%2Fmp4&gir=yes&clen=1294944&dur=212.183&lmt=1694042119353699&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246&beids=24350017&c=IOS&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhALhCksAaeDWa-W1taV5xDEjH3oA797nWmbv-PqF8pXjIAiEAsPD5sd96HyHAuWzCaIx7kjL4H3JF3XGA9VSrCSkJiZk%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIhAOmpt4JjYheZ74B5RrWD2NtuaXZ0Cc91wENOUC5FPpMaAiBOtouFd-bPf0fUCivtANHwRrUQOag_yM5njqHC6WqWDw%3D%3D", + "width": null, + "language": "en", + "language_preference": -1, + "preference": null, + "ext": "m4a", + "vcodec": "none", + "acodec": "mp4a.40.5", + "dynamic_range": null, + "container": "m4a_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": 44100, + "filesize": 3433514, + "format_id": "140", + "format_note": "medium", + "source_preference": -1, + "fps": null, + "audio_channels": 2, + "height": null, + "quality": 3.0, + "has_drm": false, + "tbr": 129.51, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805362&ei=ElkEZZexM5LZx_AP96OLyAo&ip=45.93.75.130&id=o-AFu4BNKMyT_lNrK_5ZvyErJEiYG7Ph8XYcCU7y4k3_R0&itag=140&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&vprv=1&svpuc=1&mime=audio%2Fmp4&gir=yes&clen=3433514&dur=212.091&lmt=1694042124987733&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246&beids=24350017&c=IOS&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIhAI1XaGcNaIr4WRSRuXP6Vyb_qIBD1K8XEe3bv0IEvE34AiByotJVBUYdJggV8Jr3U5Rzmea-qNEAtWtDQ7sk6I1SoA%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIhAOmpt4JjYheZ74B5RrWD2NtuaXZ0Cc91wENOUC5FPpMaAiBOtouFd-bPf0fUCivtANHwRrUQOag_yM5njqHC6WqWDw%3D%3D", + "width": null, + "language": "en", + "language_preference": -1, + "preference": null, + "ext": "m4a", + "vcodec": "none", + "acodec": "mp4a.40.2", + "dynamic_range": null, + "container": "m4a_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": 22050, + "filesize": 2086732, + "format_id": "17", + "format_note": "144p", + "source_preference": -1, + "fps": 6, + "audio_channels": 1, + "height": 144, + "quality": 0.0, + "has_drm": false, + "tbr": 78.693, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805363&ei=ElkEZezvO728x_APitqs0AM&ip=45.93.75.130&id=o-AIoAwIqqGXNpDKZy--HxgHnTKqNbNz-9C3MhXB5_meSb&itag=17&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&spc=UWF9fyaRvt95zG_QhX4TgA1XVl50FVc&vprv=1&svpuc=1&mime=video%2F3gpp&gir=yes&clen=2086732&dur=212.137&lmt=1694042486266781&mt=1694783390&fvip=1&fexp=24007246&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIgejYciSzMf3NwAnwW2epslP4HDY-03REZ0sIZpK0Jgv0CIQCSayE6q9noo1UcYTu6Ur92zHs3K8kDd5s-lCQAZA8qpw%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgCvLPKwYSnPZU87UPJZEH5fQLdcMySGRjdYAi7jq4mFMCIHLypxiXjTidMar1y-WOwfrRV5yEaxdVUXxi37AgwJ8m", + "width": 176, + "language": "en", + "language_preference": -1, + "preference": -2, + "ext": "3gp", + "vcodec": "mp4v.20.3", + "acodec": "mp4a.40.2", + "dynamic_range": null, + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": 44100, + "filesize": null, + "format_id": "18", + "format_note": "360p", + "source_preference": -1, + "fps": 25, + "audio_channels": 2, + "height": 360, + "quality": 6.0, + "has_drm": false, + "tbr": 343.32, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805363&ei=ElkEZezvO728x_APitqs0AM&ip=45.93.75.130&id=o-AIoAwIqqGXNpDKZy--HxgHnTKqNbNz-9C3MhXB5_meSb&itag=18&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&spc=UWF9fyaRvt95zG_QhX4TgA1XVl50FVc&vprv=1&svpuc=1&mime=video%2Fmp4&cnr=14&ratebypass=yes&dur=212.091&lmt=1694045104514388&mt=1694783390&fvip=1&fexp=24007246&beids=24350018&c=ANDROID&txp=4538434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Ccnr%2Cratebypass%2Cdur%2Clmt&sig=AOq0QJ8wRQIgRpujqnXsjuudRv0L6VKp87f6UHmJDZA30XmFMu277f8CIQCoJicYrPGhIB5PP13IDUbyg8lOJV-ZBgCjNfy3zcH5gA%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgCvLPKwYSnPZU87UPJZEH5fQLdcMySGRjdYAi7jq4mFMCIHLypxiXjTidMar1y-WOwfrRV5yEaxdVUXxi37AgwJ8m", + "width": 640, + "language": "en", + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "avc1.42001E", + "acodec": "mp4a.40.2", + "dynamic_range": null, + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": 44100, + "filesize": null, + "format_id": "22", + "format_note": "720p", + "source_preference": -5, + "fps": 25, + "audio_channels": 2, + "height": 720, + "quality": 8.0, + "has_drm": false, + "tbr": 762.182, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805363&ei=ElkEZezvO728x_APitqs0AM&ip=45.93.75.130&id=o-AIoAwIqqGXNpDKZy--HxgHnTKqNbNz-9C3MhXB5_meSb&itag=22&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&spc=UWF9fyaRvt95zG_QhX4TgA1XVl50FVc&vprv=1&svpuc=1&mime=video%2Fmp4&cnr=14&ratebypass=yes&dur=212.091&lmt=1694045086815467&mt=1694783390&fvip=1&fexp=24007246&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Ccnr%2Cratebypass%2Cdur%2Clmt&sig=AOq0QJ8wRAIgFpTkY1_S5uZCvTafPGCDGCcPiL6EHdfSnQtQzLWydsUCIAhEAOrTtNv4xp17n3S5Ze-IiwIwhEf-zihvjo7nVvfu&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgCvLPKwYSnPZU87UPJZEH5fQLdcMySGRjdYAi7jq4mFMCIHLypxiXjTidMar1y-WOwfrRV5yEaxdVUXxi37AgwJ8m", + "width": 1280, + "language": "en", + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "avc1.64001F", + "acodec": "mp4a.40.2", + "dynamic_range": null, + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": null, + "filesize": 31265835, + "format_id": "399", + "format_note": "1080p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 1080, + "quality": 9.0, + "has_drm": false, + "tbr": 1179.62, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805363&ei=ElkEZezvO728x_APitqs0AM&ip=45.93.75.130&id=o-AIoAwIqqGXNpDKZy--HxgHnTKqNbNz-9C3MhXB5_meSb&itag=399&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&spc=UWF9fyaRvt95zG_QhX4TgA1XVl50FVc&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=31265835&dur=212.040&lmt=1694042163788395&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246&beids=24350018&c=ANDROID&txp=4537434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRAIgUbywY0klHGizOIyhJ3cguHzwvQmw-Rmh84UDJogIWs4CIE8WUOnDhJ1z04KuH59Rw8Voa52o6qeEi4l9xadR1NU1&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgCvLPKwYSnPZU87UPJZEH5fQLdcMySGRjdYAi7jq4mFMCIHLypxiXjTidMar1y-WOwfrRV5yEaxdVUXxi37AgwJ8m", + "width": 1920, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "av01.0.08M.08", + "acodec": "none", + "dynamic_range": null, + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": null, + "filesize": 17466721, + "format_id": "398", + "format_note": "720p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 720, + "quality": 8.0, + "has_drm": false, + "tbr": 658.997, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805363&ei=ElkEZezvO728x_APitqs0AM&ip=45.93.75.130&id=o-AIoAwIqqGXNpDKZy--HxgHnTKqNbNz-9C3MhXB5_meSb&itag=398&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&spc=UWF9fyaRvt95zG_QhX4TgA1XVl50FVc&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=17466721&dur=212.040&lmt=1694042319819525&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246&beids=24350018&c=ANDROID&txp=4537434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhAJw5NF8ZwVqI5BMpvoJNn9lSZISS82fTS8fK-iq0GuL4AiEA1YylFyrP3-Gr4lmzxCLvdyKGhA9hf1um15HiYUjnlYc%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgCvLPKwYSnPZU87UPJZEH5fQLdcMySGRjdYAi7jq4mFMCIHLypxiXjTidMar1y-WOwfrRV5yEaxdVUXxi37AgwJ8m", + "width": 1280, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "av01.0.05M.08", + "acodec": "none", + "dynamic_range": null, + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": null, + "filesize": 9434981, + "format_id": "397", + "format_note": "480p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 480, + "quality": 7.0, + "has_drm": false, + "tbr": 355.969, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805363&ei=ElkEZezvO728x_APitqs0AM&ip=45.93.75.130&id=o-AIoAwIqqGXNpDKZy--HxgHnTKqNbNz-9C3MhXB5_meSb&itag=397&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&spc=UWF9fyaRvt95zG_QhX4TgA1XVl50FVc&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=9434981&dur=212.040&lmt=1694042458043976&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246&beids=24350018&c=ANDROID&txp=4537434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhAPHd2oyaIGrH0mpo37I0thAhDWpEEnOKezaYAgmzmbgeAiEAtFY_9vW2BLjFw1K5iGjpKxWgHD7EaJ8Fes90UrJ5mRQ%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgCvLPKwYSnPZU87UPJZEH5fQLdcMySGRjdYAi7jq4mFMCIHLypxiXjTidMar1y-WOwfrRV5yEaxdVUXxi37AgwJ8m", + "width": 854, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "av01.0.04M.08", + "acodec": "none", + "dynamic_range": null, + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": null, + "filesize": 5438397, + "format_id": "396", + "format_note": "360p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 360, + "quality": 6.0, + "has_drm": false, + "tbr": 205.183, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805363&ei=ElkEZezvO728x_APitqs0AM&ip=45.93.75.130&id=o-AIoAwIqqGXNpDKZy--HxgHnTKqNbNz-9C3MhXB5_meSb&itag=396&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&spc=UWF9fyaRvt95zG_QhX4TgA1XVl50FVc&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=5438397&dur=212.040&lmt=1694042190822892&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246&beids=24350018&c=ANDROID&txp=4537434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhAING9Ei_VhHF4Y-fcYjWEQojyteALIiVytzMGWi7IcEpAiEAi_XGT5o25-YUF-1klhXqPsEFYTw-rW63_RnvPFzSk7Q%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgCvLPKwYSnPZU87UPJZEH5fQLdcMySGRjdYAi7jq4mFMCIHLypxiXjTidMar1y-WOwfrRV5yEaxdVUXxi37AgwJ8m", + "width": 640, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "av01.0.01M.08", + "acodec": "none", + "dynamic_range": null, + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": null, + "filesize": 3024455, + "format_id": "395", + "format_note": "240p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 240, + "quality": 5.0, + "has_drm": false, + "tbr": 114.108, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805363&ei=ElkEZezvO728x_APitqs0AM&ip=45.93.75.130&id=o-AIoAwIqqGXNpDKZy--HxgHnTKqNbNz-9C3MhXB5_meSb&itag=395&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&spc=UWF9fyaRvt95zG_QhX4TgA1XVl50FVc&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=3024455&dur=212.040&lmt=1694042297309821&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246&beids=24350018&c=ANDROID&txp=4537434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIhAO80OlDOCpZh86wM0tBC0jbbcSSbOtmmTdRE9Fgn_rCuAiBI6VyPO_cUf3-xsQhHoaWTSJHrWniVyaWwVDQDGBHyIQ%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgCvLPKwYSnPZU87UPJZEH5fQLdcMySGRjdYAi7jq4mFMCIHLypxiXjTidMar1y-WOwfrRV5yEaxdVUXxi37AgwJ8m", + "width": 426, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "av01.0.00M.08", + "acodec": "none", + "dynamic_range": null, + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": null, + "filesize": 1416915, + "format_id": "394", + "format_note": "144p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 144, + "quality": 0.0, + "has_drm": false, + "tbr": 53.458, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805363&ei=ElkEZezvO728x_APitqs0AM&ip=45.93.75.130&id=o-AIoAwIqqGXNpDKZy--HxgHnTKqNbNz-9C3MhXB5_meSb&itag=394&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&spc=UWF9fyaRvt95zG_QhX4TgA1XVl50FVc&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=1416915&dur=212.040&lmt=1694042192787352&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246&beids=24350018&c=ANDROID&txp=4537434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIhAOv8_J7S5rrFISKrjmJrUNMZioocAJ6Q-eZmonQQMMz8AiB9pJRRZqY8dqrOkRIVlc5zizOqTwH1vSDmfPn2DGpKqg%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgCvLPKwYSnPZU87UPJZEH5fQLdcMySGRjdYAi7jq4mFMCIHLypxiXjTidMar1y-WOwfrRV5yEaxdVUXxi37AgwJ8m", + "width": 256, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "av01.0.00M.08", + "acodec": "none", + "dynamic_range": null, + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": null, + "filesize": 847252, + "format_id": "597", + "format_note": "144p", + "source_preference": -1, + "fps": 13, + "audio_channels": null, + "height": 144, + "quality": 0.0, + "has_drm": false, + "tbr": 31.959, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805363&ei=ElkEZezvO728x_APitqs0AM&ip=45.93.75.130&id=o-AIoAwIqqGXNpDKZy--HxgHnTKqNbNz-9C3MhXB5_meSb&itag=597&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&spc=UWF9fyaRvt95zG_QhX4TgA1XVl50FVc&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=847252&dur=212.080&lmt=1694042194934376&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIgPvtFHtAXWb8KFJX-R_tiWq4YW5fi5qpOphV64886bxACIQC3aIhifdH4RX1vAeiel_Z-TPaf1mYPguvMcGW8YyWW2g%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgCvLPKwYSnPZU87UPJZEH5fQLdcMySGRjdYAi7jq4mFMCIHLypxiXjTidMar1y-WOwfrRV5yEaxdVUXxi37AgwJ8m", + "width": 256, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "avc1.4d400b", + "acodec": "none", + "dynamic_range": null, + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": null, + "filesize": 643306, + "format_id": "598", + "format_note": "144p", + "source_preference": -1, + "fps": 13, + "audio_channels": null, + "height": 144, + "quality": 0.0, + "has_drm": false, + "tbr": 24.266, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805363&ei=ElkEZezvO728x_APitqs0AM&ip=45.93.75.130&id=o-AIoAwIqqGXNpDKZy--HxgHnTKqNbNz-9C3MhXB5_meSb&itag=598&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&spc=UWF9fyaRvt95zG_QhX4TgA1XVl50FVc&vprv=1&svpuc=1&mime=video%2Fwebm&gir=yes&clen=643306&dur=212.080&lmt=1694042224218554&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIgEL1hPDU89-fPzWMdN7U6tfY5AnhcS5RHoBsE5tppfUICIQC5nXz7WdNLEUBBQKTFmpFK6MIUGi5cINnVC6rJgeTnBQ%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgCvLPKwYSnPZU87UPJZEH5fQLdcMySGRjdYAi7jq4mFMCIHLypxiXjTidMar1y-WOwfrRV5yEaxdVUXxi37AgwJ8m", + "width": 256, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "vp9", + "acodec": "none", + "dynamic_range": null, + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": 48000, + "filesize": 1232413, + "format_id": "249", + "format_note": "low", + "source_preference": -1, + "fps": null, + "audio_channels": 2, + "height": null, + "quality": 2.0, + "has_drm": false, + "tbr": 46.492, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805363&ei=ElkEZezvO728x_APitqs0AM&ip=45.93.75.130&id=o-AIoAwIqqGXNpDKZy--HxgHnTKqNbNz-9C3MhXB5_meSb&itag=249&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&spc=UWF9fyaRvt95zG_QhX4TgA1XVl50FVc&vprv=1&svpuc=1&mime=audio%2Fwebm&gir=yes&clen=1232413&dur=212.061&lmt=1694040798737498&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIhANQc-4QonHhfMx6V4o3i3BLIHfzm66cVVKlwoquqHidOAiBPQR9Fbc9dWfjXbn0evYgCoBT3AwPQOpujWrLg4zFI4Q%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgCvLPKwYSnPZU87UPJZEH5fQLdcMySGRjdYAi7jq4mFMCIHLypxiXjTidMar1y-WOwfrRV5yEaxdVUXxi37AgwJ8m", + "width": null, + "language": "en", + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "none", + "acodec": "opus", + "dynamic_range": null, + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": 48000, + "filesize": 1630086, + "format_id": "250", + "format_note": "low", + "source_preference": -1, + "fps": null, + "audio_channels": 2, + "height": null, + "quality": 2.0, + "has_drm": false, + "tbr": 61.494, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805363&ei=ElkEZezvO728x_APitqs0AM&ip=45.93.75.130&id=o-AIoAwIqqGXNpDKZy--HxgHnTKqNbNz-9C3MhXB5_meSb&itag=250&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&spc=UWF9fyaRvt95zG_QhX4TgA1XVl50FVc&vprv=1&svpuc=1&mime=audio%2Fwebm&gir=yes&clen=1630086&dur=212.061&lmt=1694040798724510&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIgQvZ4OBBU-FdMxuoPJaqUPUs-7dvksMlD-VN2RPslh34CIQCVlEDiWcrdBh1WZGv3FGgER1H2M1T1k2l-ZuIPo9-tFA%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgCvLPKwYSnPZU87UPJZEH5fQLdcMySGRjdYAi7jq4mFMCIHLypxiXjTidMar1y-WOwfrRV5yEaxdVUXxi37AgwJ8m", + "width": null, + "language": "en", + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "none", + "acodec": "opus", + "dynamic_range": null, + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": 48000, + "filesize": 3437753, + "format_id": "251", + "format_note": "medium", + "source_preference": -1, + "fps": null, + "audio_channels": 2, + "height": null, + "quality": 3.0, + "has_drm": false, + "tbr": 129.689, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805363&ei=ElkEZezvO728x_APitqs0AM&ip=45.93.75.130&id=o-AIoAwIqqGXNpDKZy--HxgHnTKqNbNz-9C3MhXB5_meSb&itag=251&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&spc=UWF9fyaRvt95zG_QhX4TgA1XVl50FVc&vprv=1&svpuc=1&mime=audio%2Fwebm&gir=yes&clen=3437753&dur=212.061&lmt=1694040798752663&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRAIge5I_9guABrA2XK3v53t6qBEjTmV3US8iB_qYhyR07r8CIEcFZGoFipdzcpBQcwRioilKBeAmkWGbmk8vbQnpH9Wu&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgCvLPKwYSnPZU87UPJZEH5fQLdcMySGRjdYAi7jq4mFMCIHLypxiXjTidMar1y-WOwfrRV5yEaxdVUXxi37AgwJ8m", + "width": null, + "language": "en", + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "none", + "acodec": "opus", + "dynamic_range": null, + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": 22050, + "filesize": 817805, + "format_id": "599", + "format_note": "ultralow", + "source_preference": -1, + "fps": null, + "audio_channels": 2, + "height": null, + "quality": 1.0, + "has_drm": false, + "tbr": 30.833, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805363&ei=ElkEZezvO728x_APitqs0AM&ip=45.93.75.130&id=o-AIoAwIqqGXNpDKZy--HxgHnTKqNbNz-9C3MhXB5_meSb&itag=599&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&spc=UWF9fyaRvt95zG_QhX4TgA1XVl50FVc&vprv=1&svpuc=1&mime=audio%2Fmp4&gir=yes&clen=817805&dur=212.183&lmt=1694040788792847&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIgQa2KnHJ7Ie9aaTgWhEfZvOPky4QlVeQsVU0FuCkn5UsCIQDq7U0M6T5NZGQG4oP5RbMza_qKIxhlGIG5wHfEK7ZiUw%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgCvLPKwYSnPZU87UPJZEH5fQLdcMySGRjdYAi7jq4mFMCIHLypxiXjTidMar1y-WOwfrRV5yEaxdVUXxi37AgwJ8m", + "width": null, + "language": "en", + "language_preference": -1, + "preference": null, + "ext": "m4a", + "vcodec": "none", + "acodec": "mp4a.40.5", + "dynamic_range": null, + "container": "m4a_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": 48000, + "filesize": 832823, + "format_id": "600", + "format_note": "ultralow", + "source_preference": -1, + "fps": null, + "audio_channels": 2, + "height": null, + "quality": 1.0, + "has_drm": false, + "tbr": 31.418, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805363&ei=ElkEZezvO728x_APitqs0AM&ip=45.93.75.130&id=o-AIoAwIqqGXNpDKZy--HxgHnTKqNbNz-9C3MhXB5_meSb&itag=600&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&spc=UWF9fyaRvt95zG_QhX4TgA1XVl50FVc&vprv=1&svpuc=1&mime=audio%2Fwebm&gir=yes&clen=832823&dur=212.061&lmt=1694040798740210&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRAIgHHt5PanEXai9aHtyNSlGDogA6tj1cB5VwIOTqOkZZ3MCIAjVYpaCCY3o5S4qj4bZrJWrih4KdDKQRGbw9VPjiMtZ&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgCvLPKwYSnPZU87UPJZEH5fQLdcMySGRjdYAi7jq4mFMCIHLypxiXjTidMar1y-WOwfrRV5yEaxdVUXxi37AgwJ8m", + "width": null, + "language": "en", + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "none", + "acodec": "opus", + "dynamic_range": null, + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "format_id": "233", + "format_note": "Default", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805362/ei/ElkEZZexM5LZx_AP96OLyAo/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/233/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/goi/133/sgoap/clen%3D1294944%3Bdur%3D212.183%3Bgir%3Dyes%3Bitag%3D139%3Blmt%3D1694042119353699/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-aigzrnld/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/2095000/vprv/1/playlist_type/DVR/dover/13/txp/4532434/mt/1694783390/fvip/1/short_key/1/keepalive/yes/fexp/24007246/beids/24350017/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,goi,sgoap,vprv,playlist_type/sig/AOq0QJ8wRAIgTgioY5fxCkE2NdBAjvi2kGOL1WVJmc8TnDAVt_s6EioCIEEK5SCvGzw4AVyBfMsWOOWKNjkhAsyCCIIsOLDk9Ugy/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIhAMtTLkSF6ZAPLvpmJC_zjUQKWG1YqdXwzx-Hb3aTGoMIAiBKNjlY2LAcyjGZPAnGr89NJcy5vnt3TUBCpsAgV4COvA%3D%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805362/ei/ElkEZZexM5LZx_AP96OLyAo/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-aigzrnld/ms/au%2Conr/mv/m/mvi/3/pl/22/tx/24453782/txs/24453782%2C24453783/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/2095000/vprv/1/go/1/mt/1694783390/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/beids/24350017/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRQIhAMZ9OqWiYzU3OCk_pfIzyc0wMoEAyof6gjPHfXfA31TWAiBfhDxLaKc_GfsbOXFKYNQWzXk7O2iEdkXs94yKorsTEA%3D%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRAIgQchl8t_Unu2llA54YYtsc13C3-rAR0DSf_dbfklXOekCIC68o3Rie3AUegGkI52bX-Nav8hcyTxaP05T2nc9EJmD/file/index.m3u8", + "language": "en", + "ext": "mp4", + "protocol": "m3u8_native", + "preference": null, + "quality": -1, + "has_drm": false, + "vcodec": "none", + "source_preference": -1 + }, + { + "format_id": "234", + "format_note": "Default", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805362/ei/ElkEZZexM5LZx_AP96OLyAo/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/234/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/goi/133/sgoap/clen%3D3433514%3Bdur%3D212.091%3Bgir%3Dyes%3Bitag%3D140%3Blmt%3D1694042124987733/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-aigzrnld/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/2095000/vprv/1/playlist_type/DVR/dover/13/txp/4532434/mt/1694783390/fvip/1/short_key/1/keepalive/yes/fexp/24007246/beids/24350017/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,goi,sgoap,vprv,playlist_type/sig/AOq0QJ8wRAIgf5KpxsheUB1O3WaKDC2wo4WvlS-OfjBedxkxdt5c56kCICE--f5TvdD0Wvl2gOoDnK6OOm9o9sT-bE2e5bL3pmzk/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRgIhAJon-w6LV79CUBJyxy_N90noc1adtF4n1KtD-pe0ICWHAiEAy4WfwioZ6IMguDYrsBXRcrlciXn62hYjLZXrKipa6Ww%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805362/ei/ElkEZZexM5LZx_AP96OLyAo/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-aigzrnld/ms/au%2Conr/mv/m/mvi/3/pl/22/tx/24453782/txs/24453782%2C24453783/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/2095000/vprv/1/go/1/mt/1694783390/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/beids/24350017/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRQIhAMZ9OqWiYzU3OCk_pfIzyc0wMoEAyof6gjPHfXfA31TWAiBfhDxLaKc_GfsbOXFKYNQWzXk7O2iEdkXs94yKorsTEA%3D%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRAIgQchl8t_Unu2llA54YYtsc13C3-rAR0DSf_dbfklXOekCIC68o3Rie3AUegGkI52bX-Nav8hcyTxaP05T2nc9EJmD/file/index.m3u8", + "language": "en", + "ext": "mp4", + "protocol": "m3u8_native", + "preference": null, + "quality": -1, + "has_drm": false, + "vcodec": "none", + "source_preference": -1 + }, + { + "format_id": "229", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805362/ei/ElkEZZexM5LZx_AP96OLyAo/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/229/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/sgovp/clen%3D3019976%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D133%3Blmt%3D1694045014258984/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-aigzrnld/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/2095000/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1694783390/fvip/1/short_key/1/keepalive/yes/fexp/24007246/beids/24350017/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,sgovp,vprv,playlist_type/sig/AOq0QJ8wRQIgBPp-j3Am-0iYgXFKXfIAH5eijde-HM3DFKl0OpR70fQCIQC4gaiGlRo72lHx95Qa6RtvgLaciJ-AkEGV6YNE6C-6EA%3D%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIgXlTuT2hnBPXSwoG_OJwagXNWzIicPv66RpnHUv8nlV4CIQDoBxfnk2ipYnO4mna6vM8_lNPLbbAz09bpnypMw7IJfg%3D%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805362/ei/ElkEZZexM5LZx_AP96OLyAo/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-aigzrnld/ms/au%2Conr/mv/m/mvi/3/pl/22/tx/24453782/txs/24453782%2C24453783/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/2095000/vprv/1/go/1/mt/1694783390/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/beids/24350017/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRQIhAMZ9OqWiYzU3OCk_pfIzyc0wMoEAyof6gjPHfXfA31TWAiBfhDxLaKc_GfsbOXFKYNQWzXk7O2iEdkXs94yKorsTEA%3D%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRAIgQchl8t_Unu2llA54YYtsc13C3-rAR0DSf_dbfklXOekCIC68o3Rie3AUegGkI52bX-Nav8hcyTxaP05T2nc9EJmD/file/index.m3u8", + "tbr": 225.675, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 5, + "has_drm": false, + "width": 426, + "height": 240, + "vcodec": "avc1.4D4015", + "acodec": "none", + "dynamic_range": null, + "source_preference": -1 + }, + { + "format_id": "230", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805362/ei/ElkEZZexM5LZx_AP96OLyAo/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/230/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/sgovp/clen%3D5678772%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D134%3Blmt%3D1694045013473544/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-aigzrnld/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/2095000/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1694783390/fvip/1/short_key/1/keepalive/yes/fexp/24007246/beids/24350017/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,sgovp,vprv,playlist_type/sig/AOq0QJ8wRgIhAMg_Gnt-OwH-T3pCVW0FsgqyJmVWuiVW8GJGUiqWHYeyAiEAt1fzIQQLStsy7XfhrSbm_ifEyzca_iWDv4XNnCcPkz4%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRgIhAI70qWT2nrAHNiZzsSnZCqmgIZ5SVxhRp-46G8uv2deiAiEAjLhJMl-rePyimvtvkp9cJKzz2oBoLr_XRyRaTxRY63k%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805362/ei/ElkEZZexM5LZx_AP96OLyAo/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-aigzrnld/ms/au%2Conr/mv/m/mvi/3/pl/22/tx/24453782/txs/24453782%2C24453783/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/2095000/vprv/1/go/1/mt/1694783390/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/beids/24350017/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRQIhAMZ9OqWiYzU3OCk_pfIzyc0wMoEAyof6gjPHfXfA31TWAiBfhDxLaKc_GfsbOXFKYNQWzXk7O2iEdkXs94yKorsTEA%3D%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRAIgQchl8t_Unu2llA54YYtsc13C3-rAR0DSf_dbfklXOekCIC68o3Rie3AUegGkI52bX-Nav8hcyTxaP05T2nc9EJmD/file/index.m3u8", + "tbr": 478.155, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 6, + "has_drm": false, + "width": 640, + "height": 360, + "vcodec": "avc1.4D401E", + "acodec": "none", + "dynamic_range": null, + "source_preference": -1 + }, + { + "format_id": "231", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805362/ei/ElkEZZexM5LZx_AP96OLyAo/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/231/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/sgovp/clen%3D8683274%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D135%3Blmt%3D1694045045723793/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-aigzrnld/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/2095000/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1694783390/fvip/1/short_key/1/keepalive/yes/fexp/24007246/beids/24350017/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,sgovp,vprv,playlist_type/sig/AOq0QJ8wRgIhAIEiEzed0ARbUiLpSVQq2JrAl-q5mt5Z8Ory3CX19IfzAiEA5l5ODUGsOvLdQSkap4HUHQG8G-tU1BbxFEJn7mCboDM%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRgIhANyW25dIhGefJpers3uURRu0BoZud5MdJ3nuHhivJDj-AiEA-YkoifzSrzmDrVWcww4WSfVg4rlYX31KubnOw33qLHI%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805362/ei/ElkEZZexM5LZx_AP96OLyAo/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-aigzrnld/ms/au%2Conr/mv/m/mvi/3/pl/22/tx/24453782/txs/24453782%2C24453783/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/2095000/vprv/1/go/1/mt/1694783390/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/beids/24350017/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRQIhAMZ9OqWiYzU3OCk_pfIzyc0wMoEAyof6gjPHfXfA31TWAiBfhDxLaKc_GfsbOXFKYNQWzXk7O2iEdkXs94yKorsTEA%3D%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRAIgQchl8t_Unu2llA54YYtsc13C3-rAR0DSf_dbfklXOekCIC68o3Rie3AUegGkI52bX-Nav8hcyTxaP05T2nc9EJmD/file/index.m3u8", + "tbr": 660.067, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 7, + "has_drm": false, + "width": 854, + "height": 480, + "vcodec": "avc1.4D401E", + "acodec": "none", + "dynamic_range": null, + "source_preference": -1 + }, + { + "format_id": "232", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805362/ei/ElkEZZexM5LZx_AP96OLyAo/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/232/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/sgovp/clen%3D16780212%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D136%3Blmt%3D1694045071129751/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-aigzrnld/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/2095000/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1694783390/fvip/1/short_key/1/keepalive/yes/fexp/24007246/beids/24350017/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,sgovp,vprv,playlist_type/sig/AOq0QJ8wRQIgW8oXWkXJ2CpdS-SQ89WP-DQs8ZTV_av7rLHG3JdzjNQCIQC-qAtsOiN6HNeEKtL8I5zAEkLBN6PcVq-8YcJ0aatUfQ%3D%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIgRRfTc2NK_HV_O-8B0sJrB-dcriuq568Pw0dNWyyejKwCIQD4e2cm121hNtCJWfUY6XvUaaf7FhhSFs61YTYV2fsu8Q%3D%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805362/ei/ElkEZZexM5LZx_AP96OLyAo/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-aigzrnld/ms/au%2Conr/mv/m/mvi/3/pl/22/tx/24453782/txs/24453782%2C24453783/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/2095000/vprv/1/go/1/mt/1694783390/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/beids/24350017/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRQIhAMZ9OqWiYzU3OCk_pfIzyc0wMoEAyof6gjPHfXfA31TWAiBfhDxLaKc_GfsbOXFKYNQWzXk7O2iEdkXs94yKorsTEA%3D%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRAIgQchl8t_Unu2llA54YYtsc13C3-rAR0DSf_dbfklXOekCIC68o3Rie3AUegGkI52bX-Nav8hcyTxaP05T2nc9EJmD/file/index.m3u8", + "tbr": 1130.986, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 8, + "has_drm": false, + "width": 1280, + "height": 720, + "vcodec": "avc1.4D401F", + "acodec": "none", + "dynamic_range": null, + "source_preference": -1 + }, + { + "format_id": "269", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805362/ei/ElkEZZexM5LZx_AP96OLyAo/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/269/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/sgovp/clen%3D1863601%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D160%3Blmt%3D1694045032286738/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-aigzrnld/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/2095000/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1694783390/fvip/1/short_key/1/keepalive/yes/fexp/24007246/beids/24350017/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,sgovp,vprv,playlist_type/sig/AOq0QJ8wRgIhAIuQOqRrgPiUALERfYTPnvgiiEl3Y-hWhEShbnuYBUMzAiEAl9vW3lXvvaLtKHu65US4JiIR73cGeQJ1xuV7adBjba8%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIgQ56vCVM4B829mI4KWbI5O-HwEKsSMYue1IlTqpvhQboCIQDZwEj4aLMcBWW02pA7Gme8m_vi4AWNwU0MfZa_ic0DeA%3D%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805362/ei/ElkEZZexM5LZx_AP96OLyAo/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-aigzrnld/ms/au%2Conr/mv/m/mvi/3/pl/22/tx/24453782/txs/24453782%2C24453783/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/2095000/vprv/1/go/1/mt/1694783390/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/beids/24350017/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRQIhAMZ9OqWiYzU3OCk_pfIzyc0wMoEAyof6gjPHfXfA31TWAiBfhDxLaKc_GfsbOXFKYNQWzXk7O2iEdkXs94yKorsTEA%3D%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRAIgQchl8t_Unu2llA54YYtsc13C3-rAR0DSf_dbfklXOekCIC68o3Rie3AUegGkI52bX-Nav8hcyTxaP05T2nc9EJmD/file/index.m3u8", + "tbr": 156.229, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 0, + "has_drm": false, + "width": 256, + "height": 144, + "vcodec": "avc1.4D400C", + "acodec": "none", + "dynamic_range": null, + "source_preference": -1 + }, + { + "format_id": "270", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805362/ei/ElkEZZexM5LZx_AP96OLyAo/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/270/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/sgovp/clen%3D80166145%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D137%3Blmt%3D1694045208995966/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-aigzrnld/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/2095000/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1694783390/fvip/1/short_key/1/keepalive/yes/fexp/24007246/beids/24350017/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,sgovp,vprv,playlist_type/sig/AOq0QJ8wRAIgdZEL9m8spiRdfZ6B6ne8zjlERFmico8b_LZjVogCmQcCIFxn2zREZ79Dvu8RwzwxxbL0NvP_ewKv76B6NQScIokV/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRgIhALgcxz9T7FC0kNsu8aExdpu8tZP4qoz_2G--MUaFrqWJAiEA8n_0Yiiheef6zxz8XSmwn6nK6BITNANAEYawSdF0K3w%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805362/ei/ElkEZZexM5LZx_AP96OLyAo/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-aigzrnld/ms/au%2Conr/mv/m/mvi/3/pl/22/tx/24453782/txs/24453782%2C24453783/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/2095000/vprv/1/go/1/mt/1694783390/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/beids/24350017/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRQIhAMZ9OqWiYzU3OCk_pfIzyc0wMoEAyof6gjPHfXfA31TWAiBfhDxLaKc_GfsbOXFKYNQWzXk7O2iEdkXs94yKorsTEA%3D%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRAIgQchl8t_Unu2llA54YYtsc13C3-rAR0DSf_dbfklXOekCIC68o3Rie3AUegGkI52bX-Nav8hcyTxaP05T2nc9EJmD/file/index.m3u8", + "tbr": 4901.412, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 9, + "has_drm": false, + "width": 1920, + "height": 1080, + "vcodec": "avc1.640028", + "acodec": "none", + "dynamic_range": null, + "source_preference": -1 + }, + { + "format_id": "602", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805362/ei/ElkEZZexM5LZx_AP96OLyAo/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/602/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D643306%3Bdur%3D212.080%3Bgir%3Dyes%3Bitag%3D598%3Blmt%3D1694042224218554/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-aigzrnld/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/2095000/vprv/1/playlist_type/DVR/dover/13/txp/4532434/mt/1694783390/fvip/1/short_key/1/keepalive/yes/fexp/24007246/beids/24350017/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRQIgL4aGnMnSrGf6vcwDgtXpRKZxZsLCjySttx-s_2dnfoYCIQDQn0D8-97DZUKapbxrdeYy7oZlks8zJEFye4KNgQbpMg%3D%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRgIhANgsfOucjYXGWNMiFEgNFKxXBHxNM9wFHwyVPM_wreSZAiEAmw9jK9DAyv2E6QEK0Vm0EJZjvR-A14iGJmSC_JKy_ws%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805362/ei/ElkEZZexM5LZx_AP96OLyAo/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-aigzrnld/ms/au%2Conr/mv/m/mvi/3/pl/22/tx/24453782/txs/24453782%2C24453783/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/2095000/vprv/1/go/1/mt/1694783390/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/beids/24350017/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRQIhAMZ9OqWiYzU3OCk_pfIzyc0wMoEAyof6gjPHfXfA31TWAiBfhDxLaKc_GfsbOXFKYNQWzXk7O2iEdkXs94yKorsTEA%3D%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRAIgQchl8t_Unu2llA54YYtsc13C3-rAR0DSf_dbfklXOekCIC68o3Rie3AUegGkI52bX-Nav8hcyTxaP05T2nc9EJmD/file/index.m3u8", + "tbr": 80.559, + "ext": "mp4", + "fps": 13.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 0, + "has_drm": false, + "width": 256, + "height": 144, + "vcodec": "vp09.00.10.08", + "acodec": "none", + "dynamic_range": null, + "source_preference": -1 + }, + { + "format_id": "603", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805362/ei/ElkEZZexM5LZx_AP96OLyAo/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/603/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D2404581%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D278%3Blmt%3D1694043382822868/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-aigzrnld/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/2095000/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1694783390/fvip/1/short_key/1/keepalive/yes/fexp/24007246/beids/24350017/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRAIgfvzlhM8zg37LyLxQKYy2Cig_-bXHv87hgT7ZUPjDg54CIDYJTre0xGJN6_b5MRQ2LuKqLwaDgU5vkYkZW6BHFfXv/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIhAN0dBMwvH-jcwOsraHVWtL3jOO6FOXFcdHXX48KHsCV1AiBm1Jzw95WAd4BokAutYwe3vT_TY5Eest1rsVv8yGMOyA%3D%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805362/ei/ElkEZZexM5LZx_AP96OLyAo/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-aigzrnld/ms/au%2Conr/mv/m/mvi/3/pl/22/tx/24453782/txs/24453782%2C24453783/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/2095000/vprv/1/go/1/mt/1694783390/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/beids/24350017/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRQIhAMZ9OqWiYzU3OCk_pfIzyc0wMoEAyof6gjPHfXfA31TWAiBfhDxLaKc_GfsbOXFKYNQWzXk7O2iEdkXs94yKorsTEA%3D%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRAIgQchl8t_Unu2llA54YYtsc13C3-rAR0DSf_dbfklXOekCIC68o3Rie3AUegGkI52bX-Nav8hcyTxaP05T2nc9EJmD/file/index.m3u8", + "tbr": 153.593, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 0, + "has_drm": false, + "width": 256, + "height": 144, + "vcodec": "vp09.00.11.08", + "acodec": "none", + "dynamic_range": null, + "source_preference": -1 + }, + { + "format_id": "604", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805362/ei/ElkEZZexM5LZx_AP96OLyAo/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/604/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D4021173%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D242%3Blmt%3D1694043379783601/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-aigzrnld/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/2095000/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1694783390/fvip/1/short_key/1/keepalive/yes/fexp/24007246/beids/24350017/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRQIgNVq2A16AB11bSRbF-VemrxBW4D6CkHAA-Wvl3ViEVNECIQCOMuolJU8sVWl162GCh6n9hZj-JW2pxqSxa0TpsRYM3g%3D%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRAIgaVoQDSjlOt4m5mYFjH7OJbwy84r-osKOjTCpYB_Gx_MCIHs2KeHk3sv9lxHxI4i4IsfRyxPBu26h31LriFeRbMAV/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805362/ei/ElkEZZexM5LZx_AP96OLyAo/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-aigzrnld/ms/au%2Conr/mv/m/mvi/3/pl/22/tx/24453782/txs/24453782%2C24453783/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/2095000/vprv/1/go/1/mt/1694783390/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/beids/24350017/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRQIhAMZ9OqWiYzU3OCk_pfIzyc0wMoEAyof6gjPHfXfA31TWAiBfhDxLaKc_GfsbOXFKYNQWzXk7O2iEdkXs94yKorsTEA%3D%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRAIgQchl8t_Unu2llA54YYtsc13C3-rAR0DSf_dbfklXOekCIC68o3Rie3AUegGkI52bX-Nav8hcyTxaP05T2nc9EJmD/file/index.m3u8", + "tbr": 287.523, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 5, + "has_drm": false, + "width": 426, + "height": 240, + "vcodec": "vp09.00.20.08", + "acodec": "none", + "dynamic_range": null, + "source_preference": -1 + }, + { + "format_id": "605", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805362/ei/ElkEZZexM5LZx_AP96OLyAo/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/605/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D6902164%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D243%3Blmt%3D1694043349554753/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-aigzrnld/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/2095000/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1694783390/fvip/1/short_key/1/keepalive/yes/fexp/24007246/beids/24350017/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRgIhAPb_cJ7B9AWOarBtYrgfZTlfDHLwhd6mu1QC4PgVIQyLAiEA93DFwmoqw9mU7dn9WpJEeG6HfF3x9Xf9PsG8MWc3raU%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIgBAxC-SrAMNOq_PWmlx6YltmI8nhhxuA8CDLipHDvvPMCIQDBPC4LupVtVtqWpzUrK6UfupUzx8xLKeH5bGjpojVvvA%3D%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805362/ei/ElkEZZexM5LZx_AP96OLyAo/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-aigzrnld/ms/au%2Conr/mv/m/mvi/3/pl/22/tx/24453782/txs/24453782%2C24453783/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/2095000/vprv/1/go/1/mt/1694783390/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/beids/24350017/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRQIhAMZ9OqWiYzU3OCk_pfIzyc0wMoEAyof6gjPHfXfA31TWAiBfhDxLaKc_GfsbOXFKYNQWzXk7O2iEdkXs94yKorsTEA%3D%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRAIgQchl8t_Unu2llA54YYtsc13C3-rAR0DSf_dbfklXOekCIC68o3Rie3AUegGkI52bX-Nav8hcyTxaP05T2nc9EJmD/file/index.m3u8", + "tbr": 566.25, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 6, + "has_drm": false, + "width": 640, + "height": 360, + "vcodec": "vp09.00.21.08", + "acodec": "none", + "dynamic_range": null, + "source_preference": -1 + }, + { + "format_id": "606", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805362/ei/ElkEZZexM5LZx_AP96OLyAo/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/606/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D10927666%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D244%3Blmt%3D1694043369037289/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-aigzrnld/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/2095000/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1694783390/fvip/1/short_key/1/keepalive/yes/fexp/24007246/beids/24350017/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRQIhAPU2v3Ok8qhoofneHVY_YV_koEBOJgLURmXWcgTJ0tw0AiAFlyDgETgGfE9b4Tk7Ab7dtm5D_B16mg_7Sc8iZ0KS_w%3D%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRgIhALcSQ4fc6rlNhZEgjrtUAjk1kdEzAAbVWRURDoVcxxQDAiEAj4z31_nBsmweD2oOTcJhboJNhw_DFmuicu0Cc9JIsyQ%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805362/ei/ElkEZZexM5LZx_AP96OLyAo/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-aigzrnld/ms/au%2Conr/mv/m/mvi/3/pl/22/tx/24453782/txs/24453782%2C24453783/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/2095000/vprv/1/go/1/mt/1694783390/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/beids/24350017/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRQIhAMZ9OqWiYzU3OCk_pfIzyc0wMoEAyof6gjPHfXfA31TWAiBfhDxLaKc_GfsbOXFKYNQWzXk7O2iEdkXs94yKorsTEA%3D%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRAIgQchl8t_Unu2llA54YYtsc13C3-rAR0DSf_dbfklXOekCIC68o3Rie3AUegGkI52bX-Nav8hcyTxaP05T2nc9EJmD/file/index.m3u8", + "tbr": 733.359, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 7, + "has_drm": false, + "width": 854, + "height": 480, + "vcodec": "vp09.00.30.08", + "acodec": "none", + "dynamic_range": null, + "source_preference": -1 + }, + { + "format_id": "609", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805362/ei/ElkEZZexM5LZx_AP96OLyAo/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/609/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D15359727%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D247%3Blmt%3D1694043486219683/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-aigzrnld/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/2095000/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1694783390/fvip/1/short_key/1/keepalive/yes/fexp/24007246/beids/24350017/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRQIhAKhwT51CJqsjlys2ysTyc2QjNQ5NKJmlovv2hNKgDCdiAiA-vB8aDDkzRk69H2wRc9Au3y6RIfrJseFe0TIelfurCA%3D%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIhAPQ5kYjGqqafmkJoinmu4jJU9q8Fsp5wD-F9sMc5q-V2AiA3vmkk88cjfMOGS5AA6Qt0JA7nxQmxygSptXXEMgpirw%3D%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805362/ei/ElkEZZexM5LZx_AP96OLyAo/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-aigzrnld/ms/au%2Conr/mv/m/mvi/3/pl/22/tx/24453782/txs/24453782%2C24453783/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/2095000/vprv/1/go/1/mt/1694783390/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/beids/24350017/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRQIhAMZ9OqWiYzU3OCk_pfIzyc0wMoEAyof6gjPHfXfA31TWAiBfhDxLaKc_GfsbOXFKYNQWzXk7O2iEdkXs94yKorsTEA%3D%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRAIgQchl8t_Unu2llA54YYtsc13C3-rAR0DSf_dbfklXOekCIC68o3Rie3AUegGkI52bX-Nav8hcyTxaP05T2nc9EJmD/file/index.m3u8", + "tbr": 1179.472, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 8, + "has_drm": false, + "width": 1280, + "height": 720, + "vcodec": "vp09.00.31.08", + "acodec": "none", + "dynamic_range": null, + "source_preference": -1 + }, + { + "format_id": "614", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805362/ei/ElkEZZexM5LZx_AP96OLyAo/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/614/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D40874930%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D248%3Blmt%3D1694044655610179/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-aigzrnld/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/2095000/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1694783390/fvip/1/short_key/1/keepalive/yes/fexp/24007246/beids/24350017/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRQIgF-wK8LF-VDQhzk-7oDYPXASBvjmReT4SKM8MyTdmkwYCIQCJxCIqe4q3Ve5uVUUzoylaiO7HRqlU0WZ223DnQh5_Sw%3D%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRAIgGJz64zi14yiboHxiEB6gBWhlI04VkI7O0KApaWHeypgCIAfWBaJu4kV4KXbOl_DxAU6XCktBqc_AufdooMpB-4rx/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805362/ei/ElkEZZexM5LZx_AP96OLyAo/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-aigzrnld/ms/au%2Conr/mv/m/mvi/3/pl/22/tx/24453782/txs/24453782%2C24453783/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/2095000/vprv/1/go/1/mt/1694783390/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/beids/24350017/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRQIhAMZ9OqWiYzU3OCk_pfIzyc0wMoEAyof6gjPHfXfA31TWAiBfhDxLaKc_GfsbOXFKYNQWzXk7O2iEdkXs94yKorsTEA%3D%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRAIgQchl8t_Unu2llA54YYtsc13C3-rAR0DSf_dbfklXOekCIC68o3Rie3AUegGkI52bX-Nav8hcyTxaP05T2nc9EJmD/file/index.m3u8", + "tbr": 2831.123, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 9, + "has_drm": false, + "width": 1920, + "height": 1080, + "vcodec": "vp09.00.40.08", + "acodec": "none", + "dynamic_range": null, + "source_preference": -1 + }, + { + "format_id": "616", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805362/ei/ElkEZZexM5LZx_AP96OLyAo/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/616/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D99471214%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D356%3Blmt%3D1694043438471036/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-aigzrnld/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/2095000/vprv/1/playlist_type/DVR/dover/13/txp/4532434/mt/1694783390/fvip/1/short_key/1/keepalive/yes/fexp/24007246/beids/24350017/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRAIgL_3FtQ7DkyARhrpt_ESGW00JCz56g4BYAMMj6qvVFesCICWtjOG1-0QgTDrdNc7wjVKwF8V2I4v4Mp_wZ3djA4ub/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIgOHh6fExUbXZij78OmPTmIw5L1q7m811e-sFdMZuhpMsCIQDPPC_375zN4QNsIHe8Zf9Wf_Gu14fVDDSg6RxbN4VeBg%3D%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805362/ei/ElkEZZexM5LZx_AP96OLyAo/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-aigzrnld/ms/au%2Conr/mv/m/mvi/3/pl/22/tx/24453782/txs/24453782%2C24453783/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/2095000/vprv/1/go/1/mt/1694783390/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/beids/24350017/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRQIhAMZ9OqWiYzU3OCk_pfIzyc0wMoEAyof6gjPHfXfA31TWAiBfhDxLaKc_GfsbOXFKYNQWzXk7O2iEdkXs94yKorsTEA%3D%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRAIgQchl8t_Unu2llA54YYtsc13C3-rAR0DSf_dbfklXOekCIC68o3Rie3AUegGkI52bX-Nav8hcyTxaP05T2nc9EJmD/file/index.m3u8", + "tbr": 5704.254, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 9, + "has_drm": false, + "width": 1920, + "height": 1080, + "vcodec": "vp09.00.40.08", + "acodec": "none", + "dynamic_range": null, + "source_preference": 99, + "format_note": "Premium" + }, + { + "format_id": "sb0", + "format_note": "storyboard", + "ext": "mhtml", + "protocol": "mhtml", + "acodec": "none", + "vcodec": "none", + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L2/M$M.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCHYrXWHttYu2902drAXv1Wg3kN4g", + "width": 160, + "height": 90, + "fps": 0.5094339622641509, + "rows": 5, + "columns": 5, + "fragments": [ + { + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L2/M0.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCHYrXWHttYu2902drAXv1Wg3kN4g", + "duration": 49.07407407407407 + }, + { + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L2/M1.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCHYrXWHttYu2902drAXv1Wg3kN4g", + "duration": 49.07407407407407 + }, + { + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L2/M2.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCHYrXWHttYu2902drAXv1Wg3kN4g", + "duration": 49.07407407407407 + }, + { + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L2/M3.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCHYrXWHttYu2902drAXv1Wg3kN4g", + "duration": 49.07407407407407 + }, + { + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L2/M4.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCHYrXWHttYu2902drAXv1Wg3kN4g", + "duration": 15.703703703703724 + } + ] + }, + { + "format_id": "sb1", + "format_note": "storyboard", + "ext": "mhtml", + "protocol": "mhtml", + "acodec": "none", + "vcodec": "none", + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L1/M$M.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCTXAc1RH5-ZIGm3FlRiYRUkzQXug", + "width": 80, + "height": 45, + "fps": 0.5094339622641509, + "rows": 10, + "columns": 10, + "fragments": [ + { + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L1/M0.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCTXAc1RH5-ZIGm3FlRiYRUkzQXug", + "duration": 196.29629629629628 + }, + { + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L1/M1.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCTXAc1RH5-ZIGm3FlRiYRUkzQXug", + "duration": 15.703703703703724 + } + ] + }, + { + "format_id": "sb2", + "format_note": "storyboard", + "ext": "mhtml", + "protocol": "mhtml", + "acodec": "none", + "vcodec": "none", + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L0/default.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCSw4ypjBBVyVfNU-jl-4aLZArqkA", + "width": 48, + "height": 27, + "fps": 0.4716981132075472, + "rows": 10, + "columns": 10, + "fragments": [ + { + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L0/default.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCSw4ypjBBVyVfNU-jl-4aLZArqkA", + "duration": 212.0 + } + ] + } + ], + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/mqdefault.jpg", + "height": 180, + "width": 320, + "preference": -11 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg", + "height": 360, + "width": 480, + "preference": -7 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/sddefault.jpg", + "height": 480, + "width": 640, + "preference": -5 + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/default.webp", + "height": 90, + "width": 120, + "preference": -12 + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/mqdefault.webp", + "height": 180, + "width": 320, + "preference": -10 + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/hqdefault.webp", + "height": 360, + "width": 480, + "preference": -6 + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/sddefault.webp", + "height": 480, + "width": 640, + "preference": -4 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDd2KtelLHaNSXrI9_5K-NvTscKNw", + "height": 94, + "width": 168, + "preference": -7 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg?sqp=-oaymwEbCMQBEG5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBUpEOOWUXWkNyijQuZ4UPzp2BE-w", + "height": 110, + "width": 196, + "preference": -7 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg?sqp=-oaymwEcCPYBEIoBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLBCyhr8AqpJ1SxKVU6SyK5ODJ_IpA", + "height": 138, + "width": 246, + "preference": -7 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLB_p0PncTtkrhaNDZtntrE3gKkoYw", + "height": 188, + "width": 336, + "preference": -7 + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/maxresdefault.webp", + "height": 1080, + "width": 1920, + "preference": 0 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg", + "height": 720, + "width": 1280, + "preference": -1 + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/hq720.webp", + "preference": -2 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hq720.jpg", + "preference": -3 + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/0.webp", + "preference": -8 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/0.jpg", + "preference": -9 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/default.jpg", + "preference": -13 + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/sd1.webp", + "preference": -14 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/sd1.jpg", + "preference": -15 + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/sd2.webp", + "preference": -16 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/sd2.jpg", + "preference": -17 + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/sd3.webp", + "preference": -18 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/sd3.jpg", + "preference": -19 + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/hq1.webp", + "preference": -20 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hq1.jpg", + "preference": -21 + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/hq2.webp", + "preference": -22 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hq2.jpg", + "preference": -23 + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/hq3.webp", + "preference": -24 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hq3.jpg", + "preference": -25 + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/mq1.webp", + "preference": -26 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/mq1.jpg", + "preference": -27 + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/mq2.webp", + "preference": -28 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/mq2.jpg", + "preference": -29 + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/mq3.webp", + "preference": -30 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/mq3.jpg", + "preference": -31 + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/1.webp", + "preference": -32 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/1.jpg", + "preference": -33 + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/2.webp", + "preference": -34 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/2.jpg", + "preference": -35 + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/3.webp", + "preference": -36 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/3.jpg", + "preference": -37 + } + ], + "thumbnail": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/maxresdefault.webp", + "description": "The official video for \u201cNever Gonna Give You Up\u201d by Rick Astley\n\n\u2018Hold Me In Your Arms\u2019 \u2013 deluxe blue vinyl, 2CD and digital deluxe out 12th May 2023 Pre-order here \u2013 https://rick-astley.lnk.to/HMIYA2023ID\n\n\u201cNever Gonna Give You Up\u201d was a global smash on its release in July 1987, topping the charts in 25 countries including Rick\u2019s native UK and the US Billboard Hot 100. It also won the Brit Award for Best single in 1988. Stock Aitken and Waterman wrote and produced the track which was the lead-off single and lead track from Rick\u2019s debut LP \u201cWhenever You Need Somebody\u201d. The album was itself a UK number one and would go on to sell over 15 million copies worldwide.\n\nThe legendary video was directed by Simon West \u2013 who later went on to make Hollywood blockbusters such as Con Air, Lara Croft \u2013 Tomb Raider and The Expendables 2. The video passed the 1bn YouTube views milestone on 28 July 2021.\n\nSubscribe to the official Rick Astley YouTube channel: https://RickAstley.lnk.to/YTSubID\n\nFollow Rick Astley:\nFacebook: https://RickAstley.lnk.to/FBFollowID \nTwitter: https://RickAstley.lnk.to/TwitterID \nInstagram: https://RickAstley.lnk.to/InstagramID \nWebsite: https://RickAstley.lnk.to/storeID \nTikTok: https://RickAstley.lnk.to/TikTokID\n\nListen to Rick Astley:\nSpotify: https://RickAstley.lnk.to/SpotifyID \nApple Music: https://RickAstley.lnk.to/AppleMusicID \nAmazon Music: https://RickAstley.lnk.to/AmazonMusicID \nDeezer: https://RickAstley.lnk.to/DeezerID \n\nLyrics:\nWe\u2019re no strangers to love\nYou know the rules and so do I\nA full commitment\u2019s what I\u2019m thinking of\nYou wouldn\u2019t get this from any other guy\n\nI just wanna tell you how I\u2019m feeling\nGotta make you understand\n\nNever gonna give you up\nNever gonna let you down\nNever gonna run around and desert you\nNever gonna make you cry\nNever gonna say goodbye\nNever gonna tell a lie and hurt you\n\nWe\u2019ve known each other for so long\nYour heart\u2019s been aching but you\u2019re too shy to say it\nInside we both know what\u2019s been going on\nWe know the game and we\u2019re gonna play it\n\nAnd if you ask me how I\u2019m feeling\nDon\u2019t tell me you\u2019re too blind to see\n\nNever gonna give you up\nNever gonna let you down\nNever gonna run around and desert you\nNever gonna make you cry\nNever gonna say goodbye\nNever gonna tell a lie and hurt you\n\n#RickAstley #NeverGonnaGiveYouUp #WheneverYouNeedSomebody #OfficialMusicVideo", + "channel_id": "UCuAXFkgsw1L7xaCfnd5JJOw", + "channel_url": "https://www.youtube.com/channel/UCuAXFkgsw1L7xaCfnd5JJOw", + "duration": 212, + "view_count": 1447363306, + "average_rating": null, + "age_limit": 0, + "webpage_url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + "categories": ["Music"], + "tags": [ + "rick astley", + "Never Gonna Give You Up", + "nggyu", + "never gonna give you up lyrics", + "rick rolled", + "Rick Roll", + "rick astley official", + "rickrolled", + "Fortnite song", + "Fortnite event", + "Fortnite dance", + "fortnite never gonna give you up", + "rick roll", + "rickrolling", + "rick rolling", + "never gonna give you up", + "80s music", + "rick astley new", + "animated video", + "rickroll", + "meme songs", + "never gonna give u up lyrics", + "Rick Astley 2022", + "never gonna let you down", + "animated", + "rick rolls 2022", + "never gonna give you up karaoke" + ], + "playable_in_embed": true, + "live_status": "not_live", + "release_timestamp": null, + "_format_sort_fields": [ + "quality", + "res", + "fps", + "hdr:12", + "source", + "vcodec:vp9.2", + "channels", + "acodec", + "lang", + "proto" + ], + "automatic_captions": {}, + "subtitles": {}, + "comment_count": 2200000, + "chapters": null, + "heatmap": [], + "like_count": 16843107, + "channel": "Rick Astley", + "channel_follower_count": 3870000, + "channel_is_verified": true, + "uploader": "Rick Astley", + "uploader_id": "@RickAstleyYT", + "uploader_url": "https://www.youtube.com/@RickAstleyYT", + "upload_date": "20091025", + "availability": "public", + "__post_extractor": null, + "original_url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + "webpage_url_basename": "watch", + "webpage_url_domain": "youtube.com", + "extractor": "youtube", + "extractor_key": "Youtube" +} diff --git a/tests/components/media_extractor/fixtures/youtube_1_result.json b/tests/components/media_extractor/fixtures/youtube_1_result.json new file mode 100644 index 00000000000..0e45ef236fd --- /dev/null +++ b/tests/components/media_extractor/fixtures/youtube_1_result.json @@ -0,0 +1,2264 @@ +{ + "id": "dQw4w9WgXcQ", + "title": "Rick Astley - Never Gonna Give You Up (Official Music Video)", + "formats": [ + { + "format_id": "sb2", + "format_note": "storyboard", + "ext": "mhtml", + "protocol": "mhtml", + "acodec": "none", + "vcodec": "none", + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L0/default.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCSw4ypjBBVyVfNU-jl-4aLZArqkA", + "width": 48, + "height": 27, + "fps": 0.4716981132075472, + "rows": 10, + "columns": 10, + "fragments": [ + { + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L0/default.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCSw4ypjBBVyVfNU-jl-4aLZArqkA", + "duration": 212.0 + } + ], + "resolution": "48x27", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "none", + "video_ext": "none", + "vbr": 0, + "abr": 0, + "tbr": null, + "format": "sb2 - 48x27 (storyboard)" + }, + { + "format_id": "sb1", + "format_note": "storyboard", + "ext": "mhtml", + "protocol": "mhtml", + "acodec": "none", + "vcodec": "none", + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L1/M$M.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCTXAc1RH5-ZIGm3FlRiYRUkzQXug", + "width": 80, + "height": 45, + "fps": 0.5094339622641509, + "rows": 10, + "columns": 10, + "fragments": [ + { + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L1/M0.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCTXAc1RH5-ZIGm3FlRiYRUkzQXug", + "duration": 196.29629629629628 + }, + { + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L1/M1.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCTXAc1RH5-ZIGm3FlRiYRUkzQXug", + "duration": 15.703703703703724 + } + ], + "resolution": "80x45", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "none", + "video_ext": "none", + "vbr": 0, + "abr": 0, + "tbr": null, + "format": "sb1 - 80x45 (storyboard)" + }, + { + "format_id": "sb0", + "format_note": "storyboard", + "ext": "mhtml", + "protocol": "mhtml", + "acodec": "none", + "vcodec": "none", + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L2/M$M.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCHYrXWHttYu2902drAXv1Wg3kN4g", + "width": 160, + "height": 90, + "fps": 0.5094339622641509, + "rows": 5, + "columns": 5, + "fragments": [ + { + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L2/M0.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCHYrXWHttYu2902drAXv1Wg3kN4g", + "duration": 49.07407407407407 + }, + { + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L2/M1.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCHYrXWHttYu2902drAXv1Wg3kN4g", + "duration": 49.07407407407407 + }, + { + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L2/M2.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCHYrXWHttYu2902drAXv1Wg3kN4g", + "duration": 49.07407407407407 + }, + { + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L2/M3.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCHYrXWHttYu2902drAXv1Wg3kN4g", + "duration": 49.07407407407407 + }, + { + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L2/M4.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCHYrXWHttYu2902drAXv1Wg3kN4g", + "duration": 15.703703703703724 + } + ], + "resolution": "160x90", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "none", + "video_ext": "none", + "vbr": 0, + "abr": 0, + "tbr": null, + "format": "sb0 - 160x90 (storyboard)" + }, + { + "format_id": "233", + "format_note": "Default", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805294/ei/zlgEZcCPFpqOx_APj42f2Ao/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/233/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/goi/133/sgoap/clen%3D1294944%3Bdur%3D212.183%3Bgir%3Dyes%3Bitag%3D139%3Blmt%3D1694042119353699/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-aigzrnld/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/2095000/vprv/1/playlist_type/DVR/dover/13/txp/4532434/mt/1694783390/fvip/1/short_key/1/keepalive/yes/fexp/24007246,24362685/beids/24350017/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,goi,sgoap,vprv,playlist_type/sig/AOq0QJ8wRQIgD1CuHxyBZDp8CotqpDW-OXWwl5inwtPybJWCFn-qy74CIQDKHqzxkxy3eUBhpBGBJlFCka68OmvIx_jyzzZdwHS_cw%3D%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIhAJgReJ36jZBrK0vcRptTmPDXo5S_7sQH9MjX4TZ93-PBAiBzFPY7QeldoOL28TLyfUfHD7-ehZvD8QZsmG0QV3S2dQ%3D%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805294/ei/zlgEZcCPFpqOx_APj42f2Ao/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-aigzrnld/ms/au%2Conr/mv/m/mvi/3/pl/22/tx/24388769/txs/24388767%2C24388768%2C24388769%2C24388770%2C24388771%2C24388772%2C24388773/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/2095000/vprv/1/go/1/mt/1694783390/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246%2C24362685/beids/24350017/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRAIfSO7YqwqqN5rvPVra8z_X7uhf3eONbgq6Wd7dUkRvQAIhAN_9UHWwCMbxaNIcBGwJcd2U7eGn8mcxLopjD2_obq4_/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRQIhAJsMzACAa3MAibS4ggmRWMpBoF5cEu2OPzA18PGS6JZiAiA0ooqsQhZJHwLRL5tKAUYrAUq_eY-wsx7nWV3SAiAbCw%3D%3D/file/index.m3u8", + "language": "en", + "ext": "mp4", + "protocol": "m3u8_native", + "preference": null, + "quality": -1, + "has_drm": false, + "vcodec": "none", + "source_preference": -1, + "resolution": "audio only", + "aspect_ratio": null, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "mp4", + "video_ext": "none", + "vbr": 0, + "abr": null, + "tbr": null, + "format": "233 - audio only (Default)" + }, + { + "format_id": "234", + "format_note": "Default", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805294/ei/zlgEZcCPFpqOx_APj42f2Ao/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/234/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/goi/133/sgoap/clen%3D3433514%3Bdur%3D212.091%3Bgir%3Dyes%3Bitag%3D140%3Blmt%3D1694042124987733/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-aigzrnld/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/2095000/vprv/1/playlist_type/DVR/dover/13/txp/4532434/mt/1694783390/fvip/1/short_key/1/keepalive/yes/fexp/24007246,24362685/beids/24350017/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,goi,sgoap,vprv,playlist_type/sig/AOq0QJ8wRQIhAKKU8khD3yI3hS2p11CClk471LS1rcvDpkUkx-M8bGsXAiA5WQCNqjcSerErypAdI8V2e4piquAdGgphaj81Gv5Y_g%3D%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRgIhALcHmGhK_PjVviKLKPZdftQH-9u5ESmIwSU9N_77HqKTAiEApoSoPuw-BXgfaToSnaFFujfVaKOazoJsnqSA0PzmMuk%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805294/ei/zlgEZcCPFpqOx_APj42f2Ao/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-aigzrnld/ms/au%2Conr/mv/m/mvi/3/pl/22/tx/24388769/txs/24388767%2C24388768%2C24388769%2C24388770%2C24388771%2C24388772%2C24388773/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/2095000/vprv/1/go/1/mt/1694783390/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246%2C24362685/beids/24350017/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRAIfSO7YqwqqN5rvPVra8z_X7uhf3eONbgq6Wd7dUkRvQAIhAN_9UHWwCMbxaNIcBGwJcd2U7eGn8mcxLopjD2_obq4_/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRQIhAJsMzACAa3MAibS4ggmRWMpBoF5cEu2OPzA18PGS6JZiAiA0ooqsQhZJHwLRL5tKAUYrAUq_eY-wsx7nWV3SAiAbCw%3D%3D/file/index.m3u8", + "language": "en", + "ext": "mp4", + "protocol": "m3u8_native", + "preference": null, + "quality": -1, + "has_drm": false, + "vcodec": "none", + "source_preference": -1, + "resolution": "audio only", + "aspect_ratio": null, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "mp4", + "video_ext": "none", + "vbr": 0, + "abr": null, + "tbr": null, + "format": "234 - audio only (Default)" + }, + { + "asr": 22050, + "filesize": 817805, + "format_id": "599", + "format_note": "ultralow", + "source_preference": -1, + "fps": null, + "audio_channels": 2, + "height": null, + "quality": 1.0, + "has_drm": false, + "tbr": 30.833, + "url": "https://rr2---sn-5hnekn7k.googlevideo.com/videoplayback?expire=1694805294&ei=zlgEZaLeHcrlgAeFhLrYBA&ip=45.93.75.130&id=o-AFIa6Sil61_wuEFkUVhjKkr-0pyzj2cHi52leur2vR1j&itag=599&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hnekn7k%2Csn-5hne6nzy&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=2095000&spc=UWF9f2Ob7Uhbkv1q69SZBYEqtijLGjs&vprv=1&svpuc=1&mime=audio%2Fmp4&gir=yes&clen=817805&dur=212.183&lmt=1694040788792847&mt=1694783390&fvip=3&keepalive=yes&fexp=24007246%2C24362685&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhANrHfEiq-FeLKdAwJdMHQPDWTfAJA0rrz0xPoLvcW6FnAiEA1AtB0TyhtPL65Yh_vFDsWcbaQuqaMlnsMFvlM3p12NI%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAI4QpoB0iBj-oMiNFMMdN0RN-u3nLji437a3jqTbhncSAiEAlvsdhJjG0-VZ2jCjyUZBtidBcUzYFwnk6qG7mIiNjCA%3D", + "width": null, + "language": "en", + "language_preference": -1, + "preference": null, + "ext": "m4a", + "vcodec": "none", + "acodec": "mp4a.40.5", + "dynamic_range": null, + "container": "m4a_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "audio only", + "aspect_ratio": null, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "m4a", + "video_ext": "none", + "vbr": 0, + "abr": 30.833, + "format": "599 - audio only (ultralow)" + }, + { + "asr": 48000, + "filesize": 832823, + "format_id": "600", + "format_note": "ultralow", + "source_preference": -1, + "fps": null, + "audio_channels": 2, + "height": null, + "quality": 1.0, + "has_drm": false, + "tbr": 31.418, + "url": "https://rr2---sn-5hnekn7k.googlevideo.com/videoplayback?expire=1694805294&ei=zlgEZaLeHcrlgAeFhLrYBA&ip=45.93.75.130&id=o-AFIa6Sil61_wuEFkUVhjKkr-0pyzj2cHi52leur2vR1j&itag=600&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hnekn7k%2Csn-5hne6nzy&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=2095000&spc=UWF9f2Ob7Uhbkv1q69SZBYEqtijLGjs&vprv=1&svpuc=1&mime=audio%2Fwebm&gir=yes&clen=832823&dur=212.061&lmt=1694040798740210&mt=1694783390&fvip=3&keepalive=yes&fexp=24007246%2C24362685&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIhAMi0Gtku1QTmYFAGzEAvQnHrH-wNLXK_sblRAkQs6GKzAiAozCGsX0WxazgHTgUX2o_bziMG_TTIKBTisdTCIHdPbQ%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAI4QpoB0iBj-oMiNFMMdN0RN-u3nLji437a3jqTbhncSAiEAlvsdhJjG0-VZ2jCjyUZBtidBcUzYFwnk6qG7mIiNjCA%3D", + "width": null, + "language": "en", + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "none", + "acodec": "opus", + "dynamic_range": null, + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "audio only", + "aspect_ratio": null, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "webm", + "video_ext": "none", + "vbr": 0, + "abr": 31.418, + "format": "600 - audio only (ultralow)" + }, + { + "asr": 22050, + "filesize": 1294944, + "format_id": "139", + "format_note": "low", + "source_preference": -1, + "fps": null, + "audio_channels": 2, + "height": null, + "quality": 2.0, + "has_drm": false, + "tbr": 48.823, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805294&ei=zlgEZcCPFpqOx_APj42f2Ao&ip=45.93.75.130&id=o-AJK-SE-1BW0w1_4zhkyevHLKWnD0vrRBPNot5eVH0ogM&itag=139&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&vprv=1&svpuc=1&mime=audio%2Fmp4&gir=yes&clen=1294944&dur=212.183&lmt=1694042119353699&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246%2C24362685&beids=24350017&c=IOS&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIhAPwnDwj3V76nkrC5Ei6len9NHl7IHCSKu0J8T1KgImFDAiBHJODRlt5yaelGkhfXiSwCFkg2QxtvPY53tG6XS6X2lQ%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgMFD0fR8NqzBiP481IpIhnKJjW4Z2fLVfgKt5-OsWbxICICLr46c0ycoE_Ngo3heXuwdOWXs0nyZXegtnP5uHLJSb", + "width": null, + "language": "en", + "language_preference": -1, + "preference": null, + "ext": "m4a", + "vcodec": "none", + "acodec": "mp4a.40.5", + "dynamic_range": null, + "container": "m4a_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "audio only", + "aspect_ratio": null, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "m4a", + "video_ext": "none", + "vbr": 0, + "abr": 48.823, + "format": "139 - audio only (low)" + }, + { + "asr": 48000, + "filesize": 1232413, + "format_id": "249", + "format_note": "low", + "source_preference": -1, + "fps": null, + "audio_channels": 2, + "height": null, + "quality": 2.0, + "has_drm": false, + "tbr": 46.492, + "url": "https://rr2---sn-5hnekn7k.googlevideo.com/videoplayback?expire=1694805294&ei=zlgEZaLeHcrlgAeFhLrYBA&ip=45.93.75.130&id=o-AFIa6Sil61_wuEFkUVhjKkr-0pyzj2cHi52leur2vR1j&itag=249&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hnekn7k%2Csn-5hne6nzy&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=2095000&spc=UWF9f2Ob7Uhbkv1q69SZBYEqtijLGjs&vprv=1&svpuc=1&mime=audio%2Fwebm&gir=yes&clen=1232413&dur=212.061&lmt=1694040798737498&mt=1694783390&fvip=3&keepalive=yes&fexp=24007246%2C24362685&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIhAKdKRZBqXKnZA3vtezvG0nXkCWj-w1Y_aRo1mn3_owXiAiBj6UPDRERpzv7YbihbiK60bWG1aEgDooK9YfY89qhxew%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAI4QpoB0iBj-oMiNFMMdN0RN-u3nLji437a3jqTbhncSAiEAlvsdhJjG0-VZ2jCjyUZBtidBcUzYFwnk6qG7mIiNjCA%3D", + "width": null, + "language": "en", + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "none", + "acodec": "opus", + "dynamic_range": null, + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "audio only", + "aspect_ratio": null, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "webm", + "video_ext": "none", + "vbr": 0, + "abr": 46.492, + "format": "249 - audio only (low)" + }, + { + "asr": 48000, + "filesize": 1630086, + "format_id": "250", + "format_note": "low", + "source_preference": -1, + "fps": null, + "audio_channels": 2, + "height": null, + "quality": 2.0, + "has_drm": false, + "tbr": 61.494, + "url": "https://rr2---sn-5hnekn7k.googlevideo.com/videoplayback?expire=1694805294&ei=zlgEZaLeHcrlgAeFhLrYBA&ip=45.93.75.130&id=o-AFIa6Sil61_wuEFkUVhjKkr-0pyzj2cHi52leur2vR1j&itag=250&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hnekn7k%2Csn-5hne6nzy&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=2095000&spc=UWF9f2Ob7Uhbkv1q69SZBYEqtijLGjs&vprv=1&svpuc=1&mime=audio%2Fwebm&gir=yes&clen=1630086&dur=212.061&lmt=1694040798724510&mt=1694783390&fvip=3&keepalive=yes&fexp=24007246%2C24362685&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRAIgSbDvX7rFBu2kVQco2hdtzDWyi3YZ2VUKzJyUaSY7be8CIG5771IIeWcW8RdAAN0JxTBH643YnjiAA07Vz7CNxx_B&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAI4QpoB0iBj-oMiNFMMdN0RN-u3nLji437a3jqTbhncSAiEAlvsdhJjG0-VZ2jCjyUZBtidBcUzYFwnk6qG7mIiNjCA%3D", + "width": null, + "language": "en", + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "none", + "acodec": "opus", + "dynamic_range": null, + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "audio only", + "aspect_ratio": null, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "webm", + "video_ext": "none", + "vbr": 0, + "abr": 61.494, + "format": "250 - audio only (low)" + }, + { + "asr": 44100, + "filesize": 3433514, + "format_id": "140", + "format_note": "medium", + "source_preference": -1, + "fps": null, + "audio_channels": 2, + "height": null, + "quality": 3.0, + "has_drm": false, + "tbr": 129.51, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805294&ei=zlgEZcCPFpqOx_APj42f2Ao&ip=45.93.75.130&id=o-AJK-SE-1BW0w1_4zhkyevHLKWnD0vrRBPNot5eVH0ogM&itag=140&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&vprv=1&svpuc=1&mime=audio%2Fmp4&gir=yes&clen=3433514&dur=212.091&lmt=1694042124987733&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246%2C24362685&beids=24350017&c=IOS&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIgVTJv_gHCMf1uIjkGLTyUU-viSD3y2KYQdGNTciMiBEoCIQCAdN10CdXuAHvoTXfN6_Gv4Lzw4I0QlQ8ERgaT0hB7FA%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgMFD0fR8NqzBiP481IpIhnKJjW4Z2fLVfgKt5-OsWbxICICLr46c0ycoE_Ngo3heXuwdOWXs0nyZXegtnP5uHLJSb", + "width": null, + "language": "en", + "language_preference": -1, + "preference": null, + "ext": "m4a", + "vcodec": "none", + "acodec": "mp4a.40.2", + "dynamic_range": null, + "container": "m4a_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "audio only", + "aspect_ratio": null, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "m4a", + "video_ext": "none", + "vbr": 0, + "abr": 129.51, + "format": "140 - audio only (medium)" + }, + { + "asr": 48000, + "filesize": 3437753, + "format_id": "251", + "format_note": "medium", + "source_preference": -1, + "fps": null, + "audio_channels": 2, + "height": null, + "quality": 3.0, + "has_drm": false, + "tbr": 129.689, + "url": "https://rr2---sn-5hnekn7k.googlevideo.com/videoplayback?expire=1694805294&ei=zlgEZaLeHcrlgAeFhLrYBA&ip=45.93.75.130&id=o-AFIa6Sil61_wuEFkUVhjKkr-0pyzj2cHi52leur2vR1j&itag=251&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hnekn7k%2Csn-5hne6nzy&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=2095000&spc=UWF9f2Ob7Uhbkv1q69SZBYEqtijLGjs&vprv=1&svpuc=1&mime=audio%2Fwebm&gir=yes&clen=3437753&dur=212.061&lmt=1694040798752663&mt=1694783390&fvip=3&keepalive=yes&fexp=24007246%2C24362685&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhAK3VvF4KH-4nYQUP1gpSURVLxA9j_1qSnMFHt4a8Stk8AiEA8wi7_ubVv4HzCGjW_pWZaUBRNXJaQ-1GuAAJovlF_E8%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAI4QpoB0iBj-oMiNFMMdN0RN-u3nLji437a3jqTbhncSAiEAlvsdhJjG0-VZ2jCjyUZBtidBcUzYFwnk6qG7mIiNjCA%3D", + "width": null, + "language": "en", + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "none", + "acodec": "opus", + "dynamic_range": null, + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "audio only", + "aspect_ratio": null, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "webm", + "video_ext": "none", + "vbr": 0, + "abr": 129.689, + "format": "251 - audio only (medium)" + }, + { + "asr": 22050, + "filesize": 2086732, + "format_id": "17", + "format_note": "144p", + "source_preference": -1, + "fps": 6, + "audio_channels": 1, + "height": 144, + "quality": 0.0, + "has_drm": false, + "tbr": 78.693, + "url": "https://rr2---sn-5hnekn7k.googlevideo.com/videoplayback?expire=1694805294&ei=zlgEZaLeHcrlgAeFhLrYBA&ip=45.93.75.130&id=o-AFIa6Sil61_wuEFkUVhjKkr-0pyzj2cHi52leur2vR1j&itag=17&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hnekn7k%2Csn-5hne6nzy&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=2095000&spc=UWF9f2Ob7Uhbkv1q69SZBYEqtijLGjs&vprv=1&svpuc=1&mime=video%2F3gpp&gir=yes&clen=2086732&dur=212.137&lmt=1694042486266781&mt=1694783390&fvip=3&fexp=24007246%2C24362685&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIhAOkaEqDoIIOiYp0heUx8lnvZolT-wzM9zqxL-tgwnFK5AiBzG-hPT4NtSPfxog-4CwBC6LaXrfz49WBhD8iqDnlN5A%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAI4QpoB0iBj-oMiNFMMdN0RN-u3nLji437a3jqTbhncSAiEAlvsdhJjG0-VZ2jCjyUZBtidBcUzYFwnk6qG7mIiNjCA%3D", + "width": 176, + "language": "en", + "language_preference": -1, + "preference": -2, + "ext": "3gp", + "vcodec": "mp4v.20.3", + "acodec": "mp4a.40.2", + "dynamic_range": "SDR", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "176x144", + "aspect_ratio": 1.22, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "3gp", + "audio_ext": "none", + "vbr": null, + "abr": null, + "format": "17 - 176x144 (144p)" + }, + { + "asr": null, + "filesize": 847252, + "format_id": "597", + "format_note": "144p", + "source_preference": -1, + "fps": 13, + "audio_channels": null, + "height": 144, + "quality": 0.0, + "has_drm": false, + "tbr": 31.959, + "url": "https://rr2---sn-5hnekn7k.googlevideo.com/videoplayback?expire=1694805294&ei=zlgEZaLeHcrlgAeFhLrYBA&ip=45.93.75.130&id=o-AFIa6Sil61_wuEFkUVhjKkr-0pyzj2cHi52leur2vR1j&itag=597&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hnekn7k%2Csn-5hne6nzy&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=2095000&spc=UWF9f2Ob7Uhbkv1q69SZBYEqtijLGjs&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=847252&dur=212.080&lmt=1694042194934376&mt=1694783390&fvip=3&keepalive=yes&fexp=24007246%2C24362685&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRAIgNkcgiUaSHK0nPL5Cy1o8cF295-sKrfhj7AtTWCJmze4CIDzO5qZyAfetWP-eepABZ_tWuL90Cy4wxuxBUDkcuXnR&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAI4QpoB0iBj-oMiNFMMdN0RN-u3nLji437a3jqTbhncSAiEAlvsdhJjG0-VZ2jCjyUZBtidBcUzYFwnk6qG7mIiNjCA%3D", + "width": 256, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "avc1.4d400b", + "acodec": "none", + "dynamic_range": "SDR", + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "256x144", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 31.959, + "format": "597 - 256x144 (144p)" + }, + { + "format_id": "602", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805294/ei/zlgEZcCPFpqOx_APj42f2Ao/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/602/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D643306%3Bdur%3D212.080%3Bgir%3Dyes%3Bitag%3D598%3Blmt%3D1694042224218554/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-aigzrnld/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/2095000/vprv/1/playlist_type/DVR/dover/13/txp/4532434/mt/1694783390/fvip/1/short_key/1/keepalive/yes/fexp/24007246,24362685/beids/24350017/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRAIgeLKHue4a_2TPd76zNOTbuLKfMAVtqRZbb2lulEwZ7fECIHj-QkveXvv85Ctu_k8gsJFs-Fsj40qCgnIAVkEPTFwU/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIhAMQXMw7AiQm_32IU9e2_Ir9-CFuFyo0e2jHKHKsI8I62AiB1f8VVq68s__fU5_nAOkDRkJgFTElTGsrPiqjNqZF_Eg%3D%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805294/ei/zlgEZcCPFpqOx_APj42f2Ao/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-aigzrnld/ms/au%2Conr/mv/m/mvi/3/pl/22/tx/24388769/txs/24388767%2C24388768%2C24388769%2C24388770%2C24388771%2C24388772%2C24388773/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/2095000/vprv/1/go/1/mt/1694783390/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246%2C24362685/beids/24350017/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRAIfSO7YqwqqN5rvPVra8z_X7uhf3eONbgq6Wd7dUkRvQAIhAN_9UHWwCMbxaNIcBGwJcd2U7eGn8mcxLopjD2_obq4_/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRQIhAJsMzACAa3MAibS4ggmRWMpBoF5cEu2OPzA18PGS6JZiAiA0ooqsQhZJHwLRL5tKAUYrAUq_eY-wsx7nWV3SAiAbCw%3D%3D/file/index.m3u8", + "tbr": 80.559, + "ext": "mp4", + "fps": 13.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 0, + "has_drm": false, + "width": 256, + "height": 144, + "vcodec": "vp09.00.10.08", + "acodec": "none", + "dynamic_range": "SDR", + "source_preference": -1, + "resolution": "256x144", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 80.559, + "format": "602 - 256x144" + }, + { + "asr": null, + "filesize": 643306, + "format_id": "598", + "format_note": "144p", + "source_preference": -1, + "fps": 13, + "audio_channels": null, + "height": 144, + "quality": 0.0, + "has_drm": false, + "tbr": 24.266, + "url": "https://rr2---sn-5hnekn7k.googlevideo.com/videoplayback?expire=1694805294&ei=zlgEZaLeHcrlgAeFhLrYBA&ip=45.93.75.130&id=o-AFIa6Sil61_wuEFkUVhjKkr-0pyzj2cHi52leur2vR1j&itag=598&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hnekn7k%2Csn-5hne6nzy&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=2095000&spc=UWF9f2Ob7Uhbkv1q69SZBYEqtijLGjs&vprv=1&svpuc=1&mime=video%2Fwebm&gir=yes&clen=643306&dur=212.080&lmt=1694042224218554&mt=1694783390&fvip=3&keepalive=yes&fexp=24007246%2C24362685&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIgK9K_H5gF-Qb02XsA2LewB3Er1g1XcSPuYUFHZQckCScCIQC3_wB_AA-O6yawRHg0wqvvbkQIuOCaVBKg5BvyYGpRQQ%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAI4QpoB0iBj-oMiNFMMdN0RN-u3nLji437a3jqTbhncSAiEAlvsdhJjG0-VZ2jCjyUZBtidBcUzYFwnk6qG7mIiNjCA%3D", + "width": 256, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "vp9", + "acodec": "none", + "dynamic_range": "SDR", + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "256x144", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "webm", + "audio_ext": "none", + "abr": 0, + "vbr": 24.266, + "format": "598 - 256x144 (144p)" + }, + { + "asr": null, + "filesize": 1416915, + "format_id": "394", + "format_note": "144p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 144, + "quality": 0.0, + "has_drm": false, + "tbr": 53.458, + "url": "https://rr2---sn-5hnekn7k.googlevideo.com/videoplayback?expire=1694805294&ei=zlgEZaLeHcrlgAeFhLrYBA&ip=45.93.75.130&id=o-AFIa6Sil61_wuEFkUVhjKkr-0pyzj2cHi52leur2vR1j&itag=394&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hnekn7k%2Csn-5hne6nzy&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=2095000&spc=UWF9f2Ob7Uhbkv1q69SZBYEqtijLGjs&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=1416915&dur=212.040&lmt=1694042192787352&mt=1694783390&fvip=3&keepalive=yes&fexp=24007246%2C24362685&beids=24350018&c=ANDROID&txp=4537434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhAMQ71tBK-Dy6-3rbL2tmDLlurujtt24coHDnxECGNxdAAiEAnU81MW518svYPnihd9Rtyxy-IXDbaGLTVf-P0bVJ2tU%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAI4QpoB0iBj-oMiNFMMdN0RN-u3nLji437a3jqTbhncSAiEAlvsdhJjG0-VZ2jCjyUZBtidBcUzYFwnk6qG7mIiNjCA%3D", + "width": 256, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "av01.0.00M.08", + "acodec": "none", + "dynamic_range": "SDR", + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "256x144", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 53.458, + "format": "394 - 256x144 (144p)" + }, + { + "format_id": "269", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805294/ei/zlgEZcCPFpqOx_APj42f2Ao/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/269/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/sgovp/clen%3D1863601%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D160%3Blmt%3D1694045032286738/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-aigzrnld/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/2095000/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1694783390/fvip/1/short_key/1/keepalive/yes/fexp/24007246,24362685/beids/24350017/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,sgovp,vprv,playlist_type/sig/AOq0QJ8wRAIgXHDG-qiMqTt9YENXjuQKu13yWrpZTLp4X58aZlLiLzECIGZbZGEiOJcTJboNbRXccVkEgyOyWeLMDh6ioim-bJ_6/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRgIhAKk-D7JZ7RcOE3suuD31f7wxlRQ_tJUZNGL0A3YnHfO_AiEAoX51OvytGT03SdZAL4nKEl9WOxtBK0hPrw3zdH-Ip9I%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805294/ei/zlgEZcCPFpqOx_APj42f2Ao/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-aigzrnld/ms/au%2Conr/mv/m/mvi/3/pl/22/tx/24388769/txs/24388767%2C24388768%2C24388769%2C24388770%2C24388771%2C24388772%2C24388773/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/2095000/vprv/1/go/1/mt/1694783390/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246%2C24362685/beids/24350017/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRAIfSO7YqwqqN5rvPVra8z_X7uhf3eONbgq6Wd7dUkRvQAIhAN_9UHWwCMbxaNIcBGwJcd2U7eGn8mcxLopjD2_obq4_/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRQIhAJsMzACAa3MAibS4ggmRWMpBoF5cEu2OPzA18PGS6JZiAiA0ooqsQhZJHwLRL5tKAUYrAUq_eY-wsx7nWV3SAiAbCw%3D%3D/file/index.m3u8", + "tbr": 156.229, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 0, + "has_drm": false, + "width": 256, + "height": 144, + "vcodec": "avc1.4D400C", + "acodec": "none", + "dynamic_range": "SDR", + "source_preference": -1, + "resolution": "256x144", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 156.229, + "format": "269 - 256x144" + }, + { + "asr": null, + "filesize": 1863601, + "format_id": "160", + "format_note": "144p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 144, + "quality": 0.0, + "has_drm": false, + "tbr": 70.311, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805294&ei=zlgEZcCPFpqOx_APj42f2Ao&ip=45.93.75.130&id=o-AJK-SE-1BW0w1_4zhkyevHLKWnD0vrRBPNot5eVH0ogM&itag=160&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=1863601&dur=212.040&lmt=1694045032286738&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246%2C24362685&beids=24350017&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhAPlI72wERnjcS79xo5HcBTNPvSNOt8nm2aBbFiTVNDfLAiEAv4JfOoJbOoVFmjcYb72mKHqxy5Gs-IfwfFxOr8WGTmA%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgMFD0fR8NqzBiP481IpIhnKJjW4Z2fLVfgKt5-OsWbxICICLr46c0ycoE_Ngo3heXuwdOWXs0nyZXegtnP5uHLJSb", + "width": 256, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "avc1.4D400C", + "acodec": "none", + "dynamic_range": "SDR", + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "256x144", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 70.311, + "format": "160 - 256x144 (144p)" + }, + { + "format_id": "603", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805294/ei/zlgEZcCPFpqOx_APj42f2Ao/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/603/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D2404581%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D278%3Blmt%3D1694043382822868/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-aigzrnld/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/2095000/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1694783390/fvip/1/short_key/1/keepalive/yes/fexp/24007246,24362685/beids/24350017/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRgIhAO-F15u2InMLQc5SvMWfQu-zotQ81OmJiGMgKkddxNseAiEArLnYasxAWXcVrQ8fNQY9HAOATI6ny63HiwdzGBwKV74%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRgIhAPDKyB3Y46PlUZ20DZ9Ydx2KV8yi7vaSoTQSv8QshMyLAiEAo2Ko43q4IlTc-UjgD0biO8jwyqc6V0SklA-PHTHkp6U%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805294/ei/zlgEZcCPFpqOx_APj42f2Ao/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-aigzrnld/ms/au%2Conr/mv/m/mvi/3/pl/22/tx/24388769/txs/24388767%2C24388768%2C24388769%2C24388770%2C24388771%2C24388772%2C24388773/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/2095000/vprv/1/go/1/mt/1694783390/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246%2C24362685/beids/24350017/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRAIfSO7YqwqqN5rvPVra8z_X7uhf3eONbgq6Wd7dUkRvQAIhAN_9UHWwCMbxaNIcBGwJcd2U7eGn8mcxLopjD2_obq4_/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRQIhAJsMzACAa3MAibS4ggmRWMpBoF5cEu2OPzA18PGS6JZiAiA0ooqsQhZJHwLRL5tKAUYrAUq_eY-wsx7nWV3SAiAbCw%3D%3D/file/index.m3u8", + "tbr": 153.593, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 0, + "has_drm": false, + "width": 256, + "height": 144, + "vcodec": "vp09.00.11.08", + "acodec": "none", + "dynamic_range": "SDR", + "source_preference": -1, + "resolution": "256x144", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 153.593, + "format": "603 - 256x144" + }, + { + "asr": null, + "filesize": 2404581, + "format_id": "278", + "format_note": "144p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 144, + "quality": 0.0, + "has_drm": false, + "tbr": 90.721, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805294&ei=zlgEZcCPFpqOx_APj42f2Ao&ip=45.93.75.130&id=o-AJK-SE-1BW0w1_4zhkyevHLKWnD0vrRBPNot5eVH0ogM&itag=278&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&vprv=1&svpuc=1&mime=video%2Fwebm&gir=yes&clen=2404581&dur=212.040&lmt=1694043382822868&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246%2C24362685&beids=24350017&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRAIgR38Uy-mo-Db11DAk18HSZDAmPMZMbPgpWeI119jLmgUCIGEoJHlq7rWKW5o0Ht7Vhk7hgKOvRf20jC5pSkKY_ge6&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgMFD0fR8NqzBiP481IpIhnKJjW4Z2fLVfgKt5-OsWbxICICLr46c0ycoE_Ngo3heXuwdOWXs0nyZXegtnP5uHLJSb", + "width": 256, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "vp09.00.11.08", + "acodec": "none", + "dynamic_range": "SDR", + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "256x144", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "webm", + "audio_ext": "none", + "abr": 0, + "vbr": 90.721, + "format": "278 - 256x144 (144p)" + }, + { + "asr": null, + "filesize": 3024455, + "format_id": "395", + "format_note": "240p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 240, + "quality": 5.0, + "has_drm": false, + "tbr": 114.108, + "url": "https://rr2---sn-5hnekn7k.googlevideo.com/videoplayback?expire=1694805294&ei=zlgEZaLeHcrlgAeFhLrYBA&ip=45.93.75.130&id=o-AFIa6Sil61_wuEFkUVhjKkr-0pyzj2cHi52leur2vR1j&itag=395&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hnekn7k%2Csn-5hne6nzy&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=2095000&spc=UWF9f2Ob7Uhbkv1q69SZBYEqtijLGjs&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=3024455&dur=212.040&lmt=1694042297309821&mt=1694783390&fvip=3&keepalive=yes&fexp=24007246%2C24362685&beids=24350018&c=ANDROID&txp=4537434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRAIgQ_LnUNoK136WJWQ7NM6Ib4TTL0pE8qqbeTIQsereumACIEWmvobEcgVL1bPxIlgapDRlseS0D-1fSlalMwYCYmMX&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAI4QpoB0iBj-oMiNFMMdN0RN-u3nLji437a3jqTbhncSAiEAlvsdhJjG0-VZ2jCjyUZBtidBcUzYFwnk6qG7mIiNjCA%3D", + "width": 426, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "av01.0.00M.08", + "acodec": "none", + "dynamic_range": "SDR", + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "426x240", + "aspect_ratio": 1.77, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 114.108, + "format": "395 - 426x240 (240p)" + }, + { + "format_id": "229", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805294/ei/zlgEZcCPFpqOx_APj42f2Ao/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/229/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/sgovp/clen%3D3019976%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D133%3Blmt%3D1694045014258984/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-aigzrnld/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/2095000/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1694783390/fvip/1/short_key/1/keepalive/yes/fexp/24007246,24362685/beids/24350017/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,sgovp,vprv,playlist_type/sig/AOq0QJ8wRgIhAJgzQwyB6wO1x057WfTOaJghMLt94xgG46o_NuxA65GXAiEAhgHuPXOahAPGxpTLWR5idIWUY3cbI3q2A9iQK1OBpFA%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIhAJoGAsyUtnUu4BdMBNNvUhBk0LQUQEVLAmuzCoXVeUjWAiB3CVjAZiRgyvg9KGJ_1xZ45YwfrrZXsQ5NJUylUbumMQ%3D%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805294/ei/zlgEZcCPFpqOx_APj42f2Ao/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-aigzrnld/ms/au%2Conr/mv/m/mvi/3/pl/22/tx/24388769/txs/24388767%2C24388768%2C24388769%2C24388770%2C24388771%2C24388772%2C24388773/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/2095000/vprv/1/go/1/mt/1694783390/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246%2C24362685/beids/24350017/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRAIfSO7YqwqqN5rvPVra8z_X7uhf3eONbgq6Wd7dUkRvQAIhAN_9UHWwCMbxaNIcBGwJcd2U7eGn8mcxLopjD2_obq4_/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRQIhAJsMzACAa3MAibS4ggmRWMpBoF5cEu2OPzA18PGS6JZiAiA0ooqsQhZJHwLRL5tKAUYrAUq_eY-wsx7nWV3SAiAbCw%3D%3D/file/index.m3u8", + "tbr": 225.675, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 5, + "has_drm": false, + "width": 426, + "height": 240, + "vcodec": "avc1.4D4015", + "acodec": "none", + "dynamic_range": "SDR", + "source_preference": -1, + "resolution": "426x240", + "aspect_ratio": 1.77, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 225.675, + "format": "229 - 426x240" + }, + { + "asr": null, + "filesize": 3019976, + "format_id": "133", + "format_note": "240p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 240, + "quality": 5.0, + "has_drm": false, + "tbr": 113.939, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805294&ei=zlgEZcCPFpqOx_APj42f2Ao&ip=45.93.75.130&id=o-AJK-SE-1BW0w1_4zhkyevHLKWnD0vrRBPNot5eVH0ogM&itag=133&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=3019976&dur=212.040&lmt=1694045014258984&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246%2C24362685&beids=24350017&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRAIgHut2YjTliC05aABej0rGGmxW1jHfh2eu-MeRewXRM7MCIG8ETnFycp5DCWGD5uCr7qPN85hQ-aaBw-yrybXZgy9p&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgMFD0fR8NqzBiP481IpIhnKJjW4Z2fLVfgKt5-OsWbxICICLr46c0ycoE_Ngo3heXuwdOWXs0nyZXegtnP5uHLJSb", + "width": 426, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "avc1.4D4015", + "acodec": "none", + "dynamic_range": "SDR", + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "426x240", + "aspect_ratio": 1.77, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 113.939, + "format": "133 - 426x240 (240p)" + }, + { + "format_id": "604", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805294/ei/zlgEZcCPFpqOx_APj42f2Ao/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/604/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D4021173%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D242%3Blmt%3D1694043379783601/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-aigzrnld/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/2095000/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1694783390/fvip/1/short_key/1/keepalive/yes/fexp/24007246,24362685/beids/24350017/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRQIhALCRyJOZVKhRnvgaO_5fKWy92t-NnatjduvAjLRiRnmMAiAVQVv4H7o06wfd3W49z18aq9eGNZHX-nIgTtYjCJyXjg%3D%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIhAO6Kgomvh5PsV9sprYs2AyWgf4C7tqvLoiNvYPlEMPEZAiAeih3hs03M_P8ZTp9_3c2ciVBPwlrpHSSuFkVM_EBUxQ%3D%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805294/ei/zlgEZcCPFpqOx_APj42f2Ao/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-aigzrnld/ms/au%2Conr/mv/m/mvi/3/pl/22/tx/24388769/txs/24388767%2C24388768%2C24388769%2C24388770%2C24388771%2C24388772%2C24388773/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/2095000/vprv/1/go/1/mt/1694783390/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246%2C24362685/beids/24350017/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRAIfSO7YqwqqN5rvPVra8z_X7uhf3eONbgq6Wd7dUkRvQAIhAN_9UHWwCMbxaNIcBGwJcd2U7eGn8mcxLopjD2_obq4_/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRQIhAJsMzACAa3MAibS4ggmRWMpBoF5cEu2OPzA18PGS6JZiAiA0ooqsQhZJHwLRL5tKAUYrAUq_eY-wsx7nWV3SAiAbCw%3D%3D/file/index.m3u8", + "tbr": 287.523, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 5, + "has_drm": false, + "width": 426, + "height": 240, + "vcodec": "vp09.00.20.08", + "acodec": "none", + "dynamic_range": "SDR", + "source_preference": -1, + "resolution": "426x240", + "aspect_ratio": 1.77, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 287.523, + "format": "604 - 426x240" + }, + { + "asr": null, + "filesize": 4021173, + "format_id": "242", + "format_note": "240p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 240, + "quality": 5.0, + "has_drm": false, + "tbr": 151.713, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805294&ei=zlgEZcCPFpqOx_APj42f2Ao&ip=45.93.75.130&id=o-AJK-SE-1BW0w1_4zhkyevHLKWnD0vrRBPNot5eVH0ogM&itag=242&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&vprv=1&svpuc=1&mime=video%2Fwebm&gir=yes&clen=4021173&dur=212.040&lmt=1694043379783601&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246%2C24362685&beids=24350017&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhAPX_5nVW6KI5yNXbwGyo7bnTPbApTDwiH7-79IyjgvRgAiEAkfqdBKaX1oY2oJUqe20vZGcu9BXsx-XzaE87newCGJA%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgMFD0fR8NqzBiP481IpIhnKJjW4Z2fLVfgKt5-OsWbxICICLr46c0ycoE_Ngo3heXuwdOWXs0nyZXegtnP5uHLJSb", + "width": 426, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "vp09.00.20.08", + "acodec": "none", + "dynamic_range": "SDR", + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "426x240", + "aspect_ratio": 1.77, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "webm", + "audio_ext": "none", + "abr": 0, + "vbr": 151.713, + "format": "242 - 426x240 (240p)" + }, + { + "asr": null, + "filesize": 5438397, + "format_id": "396", + "format_note": "360p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 360, + "quality": 6.0, + "has_drm": false, + "tbr": 205.183, + "url": "https://rr2---sn-5hnekn7k.googlevideo.com/videoplayback?expire=1694805294&ei=zlgEZaLeHcrlgAeFhLrYBA&ip=45.93.75.130&id=o-AFIa6Sil61_wuEFkUVhjKkr-0pyzj2cHi52leur2vR1j&itag=396&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hnekn7k%2Csn-5hne6nzy&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=2095000&spc=UWF9f2Ob7Uhbkv1q69SZBYEqtijLGjs&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=5438397&dur=212.040&lmt=1694042190822892&mt=1694783390&fvip=3&keepalive=yes&fexp=24007246%2C24362685&beids=24350018&c=ANDROID&txp=4537434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIhAMjAVWsBxXTdrkUCjetXA0wcvNC-bE2t5WvI3uLa3SdIAiAQnYlPPGBD74X4SLekLInhQ_jzMPIr_pPMU5lqDL9Fvg%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAI4QpoB0iBj-oMiNFMMdN0RN-u3nLji437a3jqTbhncSAiEAlvsdhJjG0-VZ2jCjyUZBtidBcUzYFwnk6qG7mIiNjCA%3D", + "width": 640, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "av01.0.01M.08", + "acodec": "none", + "dynamic_range": "SDR", + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "640x360", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 205.183, + "format": "396 - 640x360 (360p)" + }, + { + "format_id": "230", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805294/ei/zlgEZcCPFpqOx_APj42f2Ao/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/230/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/sgovp/clen%3D5678772%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D134%3Blmt%3D1694045013473544/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-aigzrnld/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/2095000/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1694783390/fvip/1/short_key/1/keepalive/yes/fexp/24007246,24362685/beids/24350017/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,sgovp,vprv,playlist_type/sig/AOq0QJ8wRQIhANmML74fiXcJws0gePdsHe9jDX2IBE5czxPinkJ2Q06JAiBtNZxHZOno8IQuCepGulwmzeAQI7PmE3Tx-P1ANRoO4w%3D%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIhANuJDfazCLBzfi5AG_cynB3EUTZpp0qVyJANI-hqb-R8AiAzEfjByNCpNoHTIaDKZsHSU5XWa95fObhCBXh1u1NCZQ%3D%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805294/ei/zlgEZcCPFpqOx_APj42f2Ao/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-aigzrnld/ms/au%2Conr/mv/m/mvi/3/pl/22/tx/24388769/txs/24388767%2C24388768%2C24388769%2C24388770%2C24388771%2C24388772%2C24388773/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/2095000/vprv/1/go/1/mt/1694783390/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246%2C24362685/beids/24350017/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRAIfSO7YqwqqN5rvPVra8z_X7uhf3eONbgq6Wd7dUkRvQAIhAN_9UHWwCMbxaNIcBGwJcd2U7eGn8mcxLopjD2_obq4_/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRQIhAJsMzACAa3MAibS4ggmRWMpBoF5cEu2OPzA18PGS6JZiAiA0ooqsQhZJHwLRL5tKAUYrAUq_eY-wsx7nWV3SAiAbCw%3D%3D/file/index.m3u8", + "tbr": 478.155, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 6, + "has_drm": false, + "width": 640, + "height": 360, + "vcodec": "avc1.4D401E", + "acodec": "none", + "dynamic_range": "SDR", + "source_preference": -1, + "resolution": "640x360", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 478.155, + "format": "230 - 640x360" + }, + { + "asr": null, + "filesize": 5678772, + "format_id": "134", + "format_note": "360p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 360, + "quality": 6.0, + "has_drm": false, + "tbr": 214.252, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805294&ei=zlgEZcCPFpqOx_APj42f2Ao&ip=45.93.75.130&id=o-AJK-SE-1BW0w1_4zhkyevHLKWnD0vrRBPNot5eVH0ogM&itag=134&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=5678772&dur=212.040&lmt=1694045013473544&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246%2C24362685&beids=24350017&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIgL3r-fvk7IAbdR0fbEsuyTumh947F-bBPYFYCsUAYHn4CIQDzBcNxo109jsHnEdACdl7Aye3dRPtc1jknzHj0EyUKXQ%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgMFD0fR8NqzBiP481IpIhnKJjW4Z2fLVfgKt5-OsWbxICICLr46c0ycoE_Ngo3heXuwdOWXs0nyZXegtnP5uHLJSb", + "width": 640, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "avc1.4D401E", + "acodec": "none", + "dynamic_range": "SDR", + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "640x360", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 214.252, + "format": "134 - 640x360 (360p)" + }, + { + "asr": 44100, + "filesize": null, + "format_id": "18", + "format_note": "360p", + "source_preference": -1, + "fps": 25, + "audio_channels": 2, + "height": 360, + "quality": 6.0, + "has_drm": false, + "tbr": 343.32, + "url": "https://rr2---sn-5hnekn7k.googlevideo.com/videoplayback?expire=1694805294&ei=zlgEZaLeHcrlgAeFhLrYBA&ip=45.93.75.130&id=o-AFIa6Sil61_wuEFkUVhjKkr-0pyzj2cHi52leur2vR1j&itag=18&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hnekn7k%2Csn-5hne6nzy&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=2095000&spc=UWF9f2Ob7Uhbkv1q69SZBYEqtijLGjs&vprv=1&svpuc=1&mime=video%2Fmp4&cnr=14&ratebypass=yes&dur=212.091&lmt=1694045104514388&mt=1694783390&fvip=3&fexp=24007246%2C24362685&beids=24350018&c=ANDROID&txp=4538434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Ccnr%2Cratebypass%2Cdur%2Clmt&sig=AOq0QJ8wRQIhAM2plJS-gAJ4uYOwQeANCtU0-8ymKKS4UaDl-enTwOEWAiA1tNz59q2t1juBE3cn3kj-VdzXCOyGvOYj7rw6o0NOIg%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAI4QpoB0iBj-oMiNFMMdN0RN-u3nLji437a3jqTbhncSAiEAlvsdhJjG0-VZ2jCjyUZBtidBcUzYFwnk6qG7mIiNjCA%3D", + "width": 640, + "language": "en", + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "avc1.42001E", + "acodec": "mp4a.40.2", + "dynamic_range": "SDR", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "640x360", + "aspect_ratio": 1.78, + "filesize_approx": 9316331, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "vbr": null, + "abr": null, + "format": "18 - 640x360 (360p)" + }, + { + "format_id": "605", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805294/ei/zlgEZcCPFpqOx_APj42f2Ao/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/605/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D6902164%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D243%3Blmt%3D1694043349554753/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-aigzrnld/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/2095000/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1694783390/fvip/1/short_key/1/keepalive/yes/fexp/24007246,24362685/beids/24350017/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRQIgSTDnFspU-gaKZYPvYpx2EhksgYwTaiT9uT9mPViuElwCIQChAtsCDQV_zVd1EiCFRBSWCNKrXPIpwqwPUTkMtHybww%3D%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIgcrOrNSB0mGrwDdvEONEW9h8g8HdOO687OxlOVpCSSewCIQCH-5b2WZDblYcNCz7kPPZ36bakY-BFoTZQMRkMZuuX9g%3D%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805294/ei/zlgEZcCPFpqOx_APj42f2Ao/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-aigzrnld/ms/au%2Conr/mv/m/mvi/3/pl/22/tx/24388769/txs/24388767%2C24388768%2C24388769%2C24388770%2C24388771%2C24388772%2C24388773/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/2095000/vprv/1/go/1/mt/1694783390/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246%2C24362685/beids/24350017/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRAIfSO7YqwqqN5rvPVra8z_X7uhf3eONbgq6Wd7dUkRvQAIhAN_9UHWwCMbxaNIcBGwJcd2U7eGn8mcxLopjD2_obq4_/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRQIhAJsMzACAa3MAibS4ggmRWMpBoF5cEu2OPzA18PGS6JZiAiA0ooqsQhZJHwLRL5tKAUYrAUq_eY-wsx7nWV3SAiAbCw%3D%3D/file/index.m3u8", + "tbr": 566.25, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 6, + "has_drm": false, + "width": 640, + "height": 360, + "vcodec": "vp09.00.21.08", + "acodec": "none", + "dynamic_range": "SDR", + "source_preference": -1, + "resolution": "640x360", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 566.25, + "format": "605 - 640x360" + }, + { + "asr": null, + "filesize": 6902164, + "format_id": "243", + "format_note": "360p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 360, + "quality": 6.0, + "has_drm": false, + "tbr": 260.409, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805294&ei=zlgEZcCPFpqOx_APj42f2Ao&ip=45.93.75.130&id=o-AJK-SE-1BW0w1_4zhkyevHLKWnD0vrRBPNot5eVH0ogM&itag=243&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&vprv=1&svpuc=1&mime=video%2Fwebm&gir=yes&clen=6902164&dur=212.040&lmt=1694043349554753&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246%2C24362685&beids=24350017&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIhAKMIJKvo9ZzUZut250l_Q6BHMMQhWM90KIIBveDOKYxLAiAY9lN-HIFiF6jZAHGVSLOt7YiGJuFYkl9Jr5n3dEEdCA%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgMFD0fR8NqzBiP481IpIhnKJjW4Z2fLVfgKt5-OsWbxICICLr46c0ycoE_Ngo3heXuwdOWXs0nyZXegtnP5uHLJSb", + "width": 640, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "vp09.00.21.08", + "acodec": "none", + "dynamic_range": "SDR", + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "640x360", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "webm", + "audio_ext": "none", + "abr": 0, + "vbr": 260.409, + "format": "243 - 640x360 (360p)" + }, + { + "asr": null, + "filesize": 9434981, + "format_id": "397", + "format_note": "480p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 480, + "quality": 7.0, + "has_drm": false, + "tbr": 355.969, + "url": "https://rr2---sn-5hnekn7k.googlevideo.com/videoplayback?expire=1694805294&ei=zlgEZaLeHcrlgAeFhLrYBA&ip=45.93.75.130&id=o-AFIa6Sil61_wuEFkUVhjKkr-0pyzj2cHi52leur2vR1j&itag=397&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hnekn7k%2Csn-5hne6nzy&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=2095000&spc=UWF9f2Ob7Uhbkv1q69SZBYEqtijLGjs&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=9434981&dur=212.040&lmt=1694042458043976&mt=1694783390&fvip=3&keepalive=yes&fexp=24007246%2C24362685&beids=24350018&c=ANDROID&txp=4537434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhAK_5svyu86Pfo7WjTLuJUO_wtGwHiTD31K0zOmp36aPYAiEAzJTu5jjrzcyw7eP4QtBtwKl8aZimxTlFUPhhUZ-4bMc%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAI4QpoB0iBj-oMiNFMMdN0RN-u3nLji437a3jqTbhncSAiEAlvsdhJjG0-VZ2jCjyUZBtidBcUzYFwnk6qG7mIiNjCA%3D", + "width": 854, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "av01.0.04M.08", + "acodec": "none", + "dynamic_range": "SDR", + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "854x480", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 355.969, + "format": "397 - 854x480 (480p)" + }, + { + "format_id": "231", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805294/ei/zlgEZcCPFpqOx_APj42f2Ao/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/231/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/sgovp/clen%3D8683274%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D135%3Blmt%3D1694045045723793/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-aigzrnld/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/2095000/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1694783390/fvip/1/short_key/1/keepalive/yes/fexp/24007246,24362685/beids/24350017/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,sgovp,vprv,playlist_type/sig/AOq0QJ8wRgIhAJjhcKpL13cYSuTqOwozWtc3vVMIElJxJOfY5sS0IGV4AiEA1XXpFt7X3ChfDqtjNbjGNvgPBdqgOGzfkqJpk8usNfI%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRAIgOcutrdh0_omv24Ahe_mRQz1KF4K9coBCJwIfi4AtHvwCIBqBIniTICvGYn1FQ9xqKDJ02XwTasAGlZy_HBlz7ncW/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805294/ei/zlgEZcCPFpqOx_APj42f2Ao/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-aigzrnld/ms/au%2Conr/mv/m/mvi/3/pl/22/tx/24388769/txs/24388767%2C24388768%2C24388769%2C24388770%2C24388771%2C24388772%2C24388773/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/2095000/vprv/1/go/1/mt/1694783390/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246%2C24362685/beids/24350017/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRAIfSO7YqwqqN5rvPVra8z_X7uhf3eONbgq6Wd7dUkRvQAIhAN_9UHWwCMbxaNIcBGwJcd2U7eGn8mcxLopjD2_obq4_/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRQIhAJsMzACAa3MAibS4ggmRWMpBoF5cEu2OPzA18PGS6JZiAiA0ooqsQhZJHwLRL5tKAUYrAUq_eY-wsx7nWV3SAiAbCw%3D%3D/file/index.m3u8", + "tbr": 660.067, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 7, + "has_drm": false, + "width": 854, + "height": 480, + "vcodec": "avc1.4D401E", + "acodec": "none", + "dynamic_range": "SDR", + "source_preference": -1, + "resolution": "854x480", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 660.067, + "format": "231 - 854x480" + }, + { + "asr": null, + "filesize": 8683274, + "format_id": "135", + "format_note": "480p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 480, + "quality": 7.0, + "has_drm": false, + "tbr": 327.608, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805294&ei=zlgEZcCPFpqOx_APj42f2Ao&ip=45.93.75.130&id=o-AJK-SE-1BW0w1_4zhkyevHLKWnD0vrRBPNot5eVH0ogM&itag=135&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=8683274&dur=212.040&lmt=1694045045723793&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246%2C24362685&beids=24350017&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhAMKCkdyx1Vw0px4cUismXBqCq65A5fbK0rBmpiqThNPLAiEAyHm-eRCEtLG4kyQvZQiNsJ4sReqD_eNlIME_M4-GK7U%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgMFD0fR8NqzBiP481IpIhnKJjW4Z2fLVfgKt5-OsWbxICICLr46c0ycoE_Ngo3heXuwdOWXs0nyZXegtnP5uHLJSb", + "width": 854, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "avc1.4D401E", + "acodec": "none", + "dynamic_range": "SDR", + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "854x480", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 327.608, + "format": "135 - 854x480 (480p)" + }, + { + "format_id": "606", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805294/ei/zlgEZcCPFpqOx_APj42f2Ao/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/606/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D10927666%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D244%3Blmt%3D1694043369037289/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-aigzrnld/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/2095000/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1694783390/fvip/1/short_key/1/keepalive/yes/fexp/24007246,24362685/beids/24350017/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRQIhAP9jaGnwC_9w9kk5Ae8_1aGdqncL-qBi4bLOGsHxMO8PAiBo1mLmboJ6aZ5INyOPONteKvox5hDfDvtHQaHdKc79fg%3D%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIhALAdFndATrnlrnqD7xNQjai35FykQK1KB67wmvnZabTqAiA2utOfPU9i_llXfASJexiFLW0UH5trY7XQF55BrSfAYw%3D%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805294/ei/zlgEZcCPFpqOx_APj42f2Ao/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-aigzrnld/ms/au%2Conr/mv/m/mvi/3/pl/22/tx/24388769/txs/24388767%2C24388768%2C24388769%2C24388770%2C24388771%2C24388772%2C24388773/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/2095000/vprv/1/go/1/mt/1694783390/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246%2C24362685/beids/24350017/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRAIfSO7YqwqqN5rvPVra8z_X7uhf3eONbgq6Wd7dUkRvQAIhAN_9UHWwCMbxaNIcBGwJcd2U7eGn8mcxLopjD2_obq4_/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRQIhAJsMzACAa3MAibS4ggmRWMpBoF5cEu2OPzA18PGS6JZiAiA0ooqsQhZJHwLRL5tKAUYrAUq_eY-wsx7nWV3SAiAbCw%3D%3D/file/index.m3u8", + "tbr": 733.359, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 7, + "has_drm": false, + "width": 854, + "height": 480, + "vcodec": "vp09.00.30.08", + "acodec": "none", + "dynamic_range": "SDR", + "source_preference": -1, + "resolution": "854x480", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 733.359, + "format": "606 - 854x480" + }, + { + "asr": null, + "filesize": 10927666, + "format_id": "244", + "format_note": "480p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 480, + "quality": 7.0, + "has_drm": false, + "tbr": 412.286, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805294&ei=zlgEZcCPFpqOx_APj42f2Ao&ip=45.93.75.130&id=o-AJK-SE-1BW0w1_4zhkyevHLKWnD0vrRBPNot5eVH0ogM&itag=244&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&vprv=1&svpuc=1&mime=video%2Fwebm&gir=yes&clen=10927666&dur=212.040&lmt=1694043369037289&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246%2C24362685&beids=24350017&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRAIgL0-pDtuLHsDB8OFAYjRKvfPxzxwxJ6Qm62n4yqH2U5cCIBK1W48z_Phf1Zg4mTuRFyYXmtLN_EQ-Uc62f89wxFM1&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgMFD0fR8NqzBiP481IpIhnKJjW4Z2fLVfgKt5-OsWbxICICLr46c0ycoE_Ngo3heXuwdOWXs0nyZXegtnP5uHLJSb", + "width": 854, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "vp09.00.30.08", + "acodec": "none", + "dynamic_range": "SDR", + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "854x480", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "webm", + "audio_ext": "none", + "abr": 0, + "vbr": 412.286, + "format": "244 - 854x480 (480p)" + }, + { + "asr": 44100, + "filesize": null, + "format_id": "22", + "format_note": "720p", + "source_preference": -5, + "fps": 25, + "audio_channels": 2, + "height": 720, + "quality": 8.0, + "has_drm": false, + "tbr": 762.182, + "url": "https://rr2---sn-5hnekn7k.googlevideo.com/videoplayback?expire=1694805294&ei=zlgEZaLeHcrlgAeFhLrYBA&ip=45.93.75.130&id=o-AFIa6Sil61_wuEFkUVhjKkr-0pyzj2cHi52leur2vR1j&itag=22&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hnekn7k%2Csn-5hne6nzy&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=2095000&spc=UWF9f2Ob7Uhbkv1q69SZBYEqtijLGjs&vprv=1&svpuc=1&mime=video%2Fmp4&cnr=14&ratebypass=yes&dur=212.091&lmt=1694045086815467&mt=1694783390&fvip=3&fexp=24007246%2C24362685&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Ccnr%2Cratebypass%2Cdur%2Clmt&sig=AOq0QJ8wRAIgUiMmQEGPqT5Hb00S74LeTwF4PCN31mwbC_fUNSejdsQCIF2D11o2OXBxoLlOX00vyB1wfYLIo6dBnodrfYc9gH6y&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAI4QpoB0iBj-oMiNFMMdN0RN-u3nLji437a3jqTbhncSAiEAlvsdhJjG0-VZ2jCjyUZBtidBcUzYFwnk6qG7mIiNjCA%3D", + "width": 1280, + "language": "en", + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "avc1.64001F", + "acodec": "mp4a.40.2", + "dynamic_range": "SDR", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "1280x720", + "aspect_ratio": 1.78, + "filesize_approx": 20682570, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "vbr": null, + "abr": null, + "format": "22 - 1280x720 (720p)" + }, + { + "asr": null, + "filesize": 17466721, + "format_id": "398", + "format_note": "720p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 720, + "quality": 8.0, + "has_drm": false, + "tbr": 658.997, + "url": "https://rr2---sn-5hnekn7k.googlevideo.com/videoplayback?expire=1694805294&ei=zlgEZaLeHcrlgAeFhLrYBA&ip=45.93.75.130&id=o-AFIa6Sil61_wuEFkUVhjKkr-0pyzj2cHi52leur2vR1j&itag=398&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hnekn7k%2Csn-5hne6nzy&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=2095000&spc=UWF9f2Ob7Uhbkv1q69SZBYEqtijLGjs&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=17466721&dur=212.040&lmt=1694042319819525&mt=1694783390&fvip=3&keepalive=yes&fexp=24007246%2C24362685&beids=24350018&c=ANDROID&txp=4537434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIhAOMjiH_ZvXNYVfljzXKPfmELytttCZtylxmCbAZ2C7ZJAiAXke6jgYDRTy7fPq6ED4SO_lP7U-5PbA6mg3FPHcYF_g%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAI4QpoB0iBj-oMiNFMMdN0RN-u3nLji437a3jqTbhncSAiEAlvsdhJjG0-VZ2jCjyUZBtidBcUzYFwnk6qG7mIiNjCA%3D", + "width": 1280, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "av01.0.05M.08", + "acodec": "none", + "dynamic_range": "SDR", + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "1280x720", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 658.997, + "format": "398 - 1280x720 (720p)" + }, + { + "format_id": "232", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805294/ei/zlgEZcCPFpqOx_APj42f2Ao/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/232/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/sgovp/clen%3D16780212%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D136%3Blmt%3D1694045071129751/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-aigzrnld/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/2095000/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1694783390/fvip/1/short_key/1/keepalive/yes/fexp/24007246,24362685/beids/24350017/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,sgovp,vprv,playlist_type/sig/AOq0QJ8wRAIgB_C4DqZzun57wSdplvOFuDDdCtqmSLzWbueqYfdgzkgCIDp_A_H9Wxm4dE1jUHl4FV1bMmarvzaxyLal_w3do31_/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIgD0yRmTVna0IUM6A-Y6SdDSJt2K1yyXW1bfCtI6a5NX4CIQDky7ka6pSZ1eg3-QZ0I20aUun70hlJ3ltkFjl9jlrQUQ%3D%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805294/ei/zlgEZcCPFpqOx_APj42f2Ao/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-aigzrnld/ms/au%2Conr/mv/m/mvi/3/pl/22/tx/24388769/txs/24388767%2C24388768%2C24388769%2C24388770%2C24388771%2C24388772%2C24388773/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/2095000/vprv/1/go/1/mt/1694783390/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246%2C24362685/beids/24350017/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRAIfSO7YqwqqN5rvPVra8z_X7uhf3eONbgq6Wd7dUkRvQAIhAN_9UHWwCMbxaNIcBGwJcd2U7eGn8mcxLopjD2_obq4_/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRQIhAJsMzACAa3MAibS4ggmRWMpBoF5cEu2OPzA18PGS6JZiAiA0ooqsQhZJHwLRL5tKAUYrAUq_eY-wsx7nWV3SAiAbCw%3D%3D/file/index.m3u8", + "tbr": 1130.986, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 8, + "has_drm": false, + "width": 1280, + "height": 720, + "vcodec": "avc1.4D401F", + "acodec": "none", + "dynamic_range": "SDR", + "source_preference": -1, + "resolution": "1280x720", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 1130.986, + "format": "232 - 1280x720" + }, + { + "asr": null, + "filesize": 16780212, + "format_id": "136", + "format_note": "720p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 720, + "quality": 8.0, + "has_drm": false, + "tbr": 633.096, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805294&ei=zlgEZcCPFpqOx_APj42f2Ao&ip=45.93.75.130&id=o-AJK-SE-1BW0w1_4zhkyevHLKWnD0vrRBPNot5eVH0ogM&itag=136&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=16780212&dur=212.040&lmt=1694045071129751&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246%2C24362685&beids=24350017&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIgCtqAnTmQ_vnafuFtCf39bKWqmxUsES5NVLA6oZCa8FoCIQDoL2Mw4vm2X3dJ5cqimgqKgl7kKU10Lf5aITgxZYexTg%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgMFD0fR8NqzBiP481IpIhnKJjW4Z2fLVfgKt5-OsWbxICICLr46c0ycoE_Ngo3heXuwdOWXs0nyZXegtnP5uHLJSb", + "width": 1280, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "avc1.4D401F", + "acodec": "none", + "dynamic_range": "SDR", + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "1280x720", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 633.096, + "format": "136 - 1280x720 (720p)" + }, + { + "format_id": "609", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805294/ei/zlgEZcCPFpqOx_APj42f2Ao/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/609/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D15359727%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D247%3Blmt%3D1694043486219683/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-aigzrnld/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/2095000/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1694783390/fvip/1/short_key/1/keepalive/yes/fexp/24007246,24362685/beids/24350017/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRAIgc_VDzoquSQo37r9Zrx7xUemxDZg31Gb7gPjXT0D0LmUCIAy9SXjTLjh3dLu7i_nnpRLsBLEBVxMGrfxfZkDcxa7l/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRAIgF994quw-ths0iLJ_bCI1ZZsDNG5k5xad4eNpG87dDzwCIDN1_u-mBatUaDeowC-Lmvy30DrlI6F3D2PWls2QAk63/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805294/ei/zlgEZcCPFpqOx_APj42f2Ao/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-aigzrnld/ms/au%2Conr/mv/m/mvi/3/pl/22/tx/24388769/txs/24388767%2C24388768%2C24388769%2C24388770%2C24388771%2C24388772%2C24388773/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/2095000/vprv/1/go/1/mt/1694783390/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246%2C24362685/beids/24350017/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRAIfSO7YqwqqN5rvPVra8z_X7uhf3eONbgq6Wd7dUkRvQAIhAN_9UHWwCMbxaNIcBGwJcd2U7eGn8mcxLopjD2_obq4_/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRQIhAJsMzACAa3MAibS4ggmRWMpBoF5cEu2OPzA18PGS6JZiAiA0ooqsQhZJHwLRL5tKAUYrAUq_eY-wsx7nWV3SAiAbCw%3D%3D/file/index.m3u8", + "tbr": 1179.472, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 8, + "has_drm": false, + "width": 1280, + "height": 720, + "vcodec": "vp09.00.31.08", + "acodec": "none", + "dynamic_range": "SDR", + "source_preference": -1, + "resolution": "1280x720", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 1179.472, + "format": "609 - 1280x720" + }, + { + "asr": null, + "filesize": 15359727, + "format_id": "247", + "format_note": "720p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 720, + "quality": 8.0, + "has_drm": false, + "tbr": 579.502, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805294&ei=zlgEZcCPFpqOx_APj42f2Ao&ip=45.93.75.130&id=o-AJK-SE-1BW0w1_4zhkyevHLKWnD0vrRBPNot5eVH0ogM&itag=247&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&vprv=1&svpuc=1&mime=video%2Fwebm&gir=yes&clen=15359727&dur=212.040&lmt=1694043486219683&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246%2C24362685&beids=24350017&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRAIgZdVLyU9Y44I-7r3MCYGZXLUXt5JLnmOfbbtGDiWROe8CIGNW8E_zs2cqrb2heIQAmZ-7ykm1zie4gIHtDJvjOKR_&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgMFD0fR8NqzBiP481IpIhnKJjW4Z2fLVfgKt5-OsWbxICICLr46c0ycoE_Ngo3heXuwdOWXs0nyZXegtnP5uHLJSb", + "width": 1280, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "vp09.00.31.08", + "acodec": "none", + "dynamic_range": "SDR", + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "1280x720", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "webm", + "audio_ext": "none", + "abr": 0, + "vbr": 579.502, + "format": "247 - 1280x720 (720p)" + }, + { + "asr": null, + "filesize": 31265835, + "format_id": "399", + "format_note": "1080p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 1080, + "quality": 9.0, + "has_drm": false, + "tbr": 1179.62, + "url": "https://rr2---sn-5hnekn7k.googlevideo.com/videoplayback?expire=1694805294&ei=zlgEZaLeHcrlgAeFhLrYBA&ip=45.93.75.130&id=o-AFIa6Sil61_wuEFkUVhjKkr-0pyzj2cHi52leur2vR1j&itag=399&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hnekn7k%2Csn-5hne6nzy&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=2095000&spc=UWF9f2Ob7Uhbkv1q69SZBYEqtijLGjs&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=31265835&dur=212.040&lmt=1694042163788395&mt=1694783390&fvip=3&keepalive=yes&fexp=24007246%2C24362685&beids=24350018&c=ANDROID&txp=4537434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRAIgaP2Au7hm10GJK8rLUstgbohBMf_KAqVJQ-RV1SvEPTcCICJM1qRtPrrp0fago3OU4jfaQ4VhAva4ZtroMTsmTR_7&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAI4QpoB0iBj-oMiNFMMdN0RN-u3nLji437a3jqTbhncSAiEAlvsdhJjG0-VZ2jCjyUZBtidBcUzYFwnk6qG7mIiNjCA%3D", + "width": 1920, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "av01.0.08M.08", + "acodec": "none", + "dynamic_range": "SDR", + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "1920x1080", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 1179.62, + "format": "399 - 1920x1080 (1080p)" + }, + { + "format_id": "270", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805294/ei/zlgEZcCPFpqOx_APj42f2Ao/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/270/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/sgovp/clen%3D80166145%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D137%3Blmt%3D1694045208995966/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-aigzrnld/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/2095000/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1694783390/fvip/1/short_key/1/keepalive/yes/fexp/24007246,24362685/beids/24350017/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,sgovp,vprv,playlist_type/sig/AOq0QJ8wRgIhAM0y0sYDi_o6cMdjgIyZc4c26MejFqEjNYogAXG76vjFAiEAqHQO47HsbIdySLwNUTarth-alwesIA8Dz_MSkJGWiMY%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRgIhANuIQTP4TOkSEdAZBezPfFFqoqLly7XJWputNoCFAhaSAiEAk4Ix-6J6ArGc1O_riDbswfSZJk3pG5LjYIDNzQ6u7h0%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805294/ei/zlgEZcCPFpqOx_APj42f2Ao/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-aigzrnld/ms/au%2Conr/mv/m/mvi/3/pl/22/tx/24388769/txs/24388767%2C24388768%2C24388769%2C24388770%2C24388771%2C24388772%2C24388773/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/2095000/vprv/1/go/1/mt/1694783390/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246%2C24362685/beids/24350017/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRAIfSO7YqwqqN5rvPVra8z_X7uhf3eONbgq6Wd7dUkRvQAIhAN_9UHWwCMbxaNIcBGwJcd2U7eGn8mcxLopjD2_obq4_/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRQIhAJsMzACAa3MAibS4ggmRWMpBoF5cEu2OPzA18PGS6JZiAiA0ooqsQhZJHwLRL5tKAUYrAUq_eY-wsx7nWV3SAiAbCw%3D%3D/file/index.m3u8", + "tbr": 4901.412, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 9, + "has_drm": false, + "width": 1920, + "height": 1080, + "vcodec": "avc1.640028", + "acodec": "none", + "dynamic_range": "SDR", + "source_preference": -1, + "resolution": "1920x1080", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 4901.412, + "format": "270 - 1920x1080" + }, + { + "asr": null, + "filesize": 80166145, + "format_id": "137", + "format_note": "1080p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 1080, + "quality": 9.0, + "has_drm": false, + "tbr": 3024.566, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805294&ei=zlgEZcCPFpqOx_APj42f2Ao&ip=45.93.75.130&id=o-AJK-SE-1BW0w1_4zhkyevHLKWnD0vrRBPNot5eVH0ogM&itag=137&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=80166145&dur=212.040&lmt=1694045208995966&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246%2C24362685&beids=24350017&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRAIgLHR5OaifEXrvnRV5vM_bxVJCDyCoTnbcD-q7gb86aJ4CIEE8XcDz57sm8-qnMgoQvn69Alel4tavulazkCswAVhj&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgMFD0fR8NqzBiP481IpIhnKJjW4Z2fLVfgKt5-OsWbxICICLr46c0ycoE_Ngo3heXuwdOWXs0nyZXegtnP5uHLJSb", + "width": 1920, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "avc1.640028", + "acodec": "none", + "dynamic_range": "SDR", + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "1920x1080", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 3024.566, + "format": "137 - 1920x1080 (1080p)" + }, + { + "format_id": "614", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805294/ei/zlgEZcCPFpqOx_APj42f2Ao/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/614/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D40874930%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D248%3Blmt%3D1694044655610179/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-aigzrnld/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/2095000/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1694783390/fvip/1/short_key/1/keepalive/yes/fexp/24007246,24362685/beids/24350017/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRAIgcC_QI0YD1K_tNrBro7SLA1_zJNvdDhjCqyZN6QnpYUQCIGfErG6d1jwWpFxNrCknBd9CIc7UZGed8OcXzoveMrWl/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIhANx6s6QBPAyCEC7yZmO9ZiY4o-ZE3keVlqtm4bQePIb8AiAY07-LkxMj6o1_LCBeGKER6AsL7rYXU1K2Gy7f5jZNhw%3D%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805294/ei/zlgEZcCPFpqOx_APj42f2Ao/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-aigzrnld/ms/au%2Conr/mv/m/mvi/3/pl/22/tx/24388769/txs/24388767%2C24388768%2C24388769%2C24388770%2C24388771%2C24388772%2C24388773/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/2095000/vprv/1/go/1/mt/1694783390/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246%2C24362685/beids/24350017/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRAIfSO7YqwqqN5rvPVra8z_X7uhf3eONbgq6Wd7dUkRvQAIhAN_9UHWwCMbxaNIcBGwJcd2U7eGn8mcxLopjD2_obq4_/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRQIhAJsMzACAa3MAibS4ggmRWMpBoF5cEu2OPzA18PGS6JZiAiA0ooqsQhZJHwLRL5tKAUYrAUq_eY-wsx7nWV3SAiAbCw%3D%3D/file/index.m3u8", + "tbr": 2831.123, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 9, + "has_drm": false, + "width": 1920, + "height": 1080, + "vcodec": "vp09.00.40.08", + "acodec": "none", + "dynamic_range": "SDR", + "source_preference": -1, + "resolution": "1920x1080", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 2831.123, + "format": "614 - 1920x1080" + }, + { + "asr": null, + "filesize": 40874930, + "format_id": "248", + "format_note": "1080p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 1080, + "quality": 9.0, + "has_drm": false, + "tbr": 1542.159, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805294&ei=zlgEZcCPFpqOx_APj42f2Ao&ip=45.93.75.130&id=o-AJK-SE-1BW0w1_4zhkyevHLKWnD0vrRBPNot5eVH0ogM&itag=248&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&vprv=1&svpuc=1&mime=video%2Fwebm&gir=yes&clen=40874930&dur=212.040&lmt=1694044655610179&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246%2C24362685&beids=24350017&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhAJ-5AjGgFTR1w-qObfMtwCvs07CU5OUDG7bsNqAXrZMxAiEA4pJO9wj-ZQTqFHg5OP2_XZIJbog8NvY8BVSwENMwJfM%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgMFD0fR8NqzBiP481IpIhnKJjW4Z2fLVfgKt5-OsWbxICICLr46c0ycoE_Ngo3heXuwdOWXs0nyZXegtnP5uHLJSb", + "width": 1920, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "vp09.00.40.08", + "acodec": "none", + "dynamic_range": "SDR", + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "1920x1080", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "webm", + "audio_ext": "none", + "abr": 0, + "vbr": 1542.159, + "format": "248 - 1920x1080 (1080p)" + }, + { + "format_id": "616", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805294/ei/zlgEZcCPFpqOx_APj42f2Ao/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/616/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D99471214%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D356%3Blmt%3D1694043438471036/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-aigzrnld/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/2095000/vprv/1/playlist_type/DVR/dover/13/txp/4532434/mt/1694783390/fvip/1/short_key/1/keepalive/yes/fexp/24007246,24362685/beids/24350017/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRgIhANCPwWNfq6wBp1Xo1L8bRJpDrzOyv7kfH_J65cZ_PRZLAiEAwo-0wQgeIjPe7OgyAAvMCx_A9wd1h8Qyh7VntKwGJUs%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIgIqS9Ub_6L9ScKXr0T9bkeu6TZsEsyNApYfF_MqeukqECIQCMSeJ1sSEw5QGMgHAW8Fhsir4TYHEK5KVg-PzJbrT6hw%3D%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805294/ei/zlgEZcCPFpqOx_APj42f2Ao/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-aigzrnld/ms/au%2Conr/mv/m/mvi/3/pl/22/tx/24388769/txs/24388767%2C24388768%2C24388769%2C24388770%2C24388771%2C24388772%2C24388773/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/2095000/vprv/1/go/1/mt/1694783390/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246%2C24362685/beids/24350017/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRAIfSO7YqwqqN5rvPVra8z_X7uhf3eONbgq6Wd7dUkRvQAIhAN_9UHWwCMbxaNIcBGwJcd2U7eGn8mcxLopjD2_obq4_/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRQIhAJsMzACAa3MAibS4ggmRWMpBoF5cEu2OPzA18PGS6JZiAiA0ooqsQhZJHwLRL5tKAUYrAUq_eY-wsx7nWV3SAiAbCw%3D%3D/file/index.m3u8", + "tbr": 5704.254, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 9, + "has_drm": false, + "width": 1920, + "height": 1080, + "vcodec": "vp09.00.40.08", + "acodec": "none", + "dynamic_range": "SDR", + "source_preference": 99, + "format_note": "Premium", + "resolution": "1920x1080", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 5704.254, + "format": "616 - 1920x1080 (Premium)" + } + ], + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/3.jpg", + "preference": -37, + "id": "0" + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/3.webp", + "preference": -36, + "id": "1" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/2.jpg", + "preference": -35, + "id": "2" + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/2.webp", + "preference": -34, + "id": "3" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/1.jpg", + "preference": -33, + "id": "4" + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/1.webp", + "preference": -32, + "id": "5" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/mq3.jpg", + "preference": -31, + "id": "6" + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/mq3.webp", + "preference": -30, + "id": "7" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/mq2.jpg", + "preference": -29, + "id": "8" + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/mq2.webp", + "preference": -28, + "id": "9" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/mq1.jpg", + "preference": -27, + "id": "10" + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/mq1.webp", + "preference": -26, + "id": "11" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hq3.jpg", + "preference": -25, + "id": "12" + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/hq3.webp", + "preference": -24, + "id": "13" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hq2.jpg", + "preference": -23, + "id": "14" + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/hq2.webp", + "preference": -22, + "id": "15" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hq1.jpg", + "preference": -21, + "id": "16" + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/hq1.webp", + "preference": -20, + "id": "17" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/sd3.jpg", + "preference": -19, + "id": "18" + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/sd3.webp", + "preference": -18, + "id": "19" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/sd2.jpg", + "preference": -17, + "id": "20" + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/sd2.webp", + "preference": -16, + "id": "21" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/sd1.jpg", + "preference": -15, + "id": "22" + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/sd1.webp", + "preference": -14, + "id": "23" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/default.jpg", + "preference": -13, + "id": "24" + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/default.webp", + "height": 90, + "width": 120, + "preference": -12, + "id": "25", + "resolution": "120x90" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/mqdefault.jpg", + "height": 180, + "width": 320, + "preference": -11, + "id": "26", + "resolution": "320x180" + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/mqdefault.webp", + "height": 180, + "width": 320, + "preference": -10, + "id": "27", + "resolution": "320x180" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/0.jpg", + "preference": -9, + "id": "28" + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/0.webp", + "preference": -8, + "id": "29" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDd2KtelLHaNSXrI9_5K-NvTscKNw", + "height": 94, + "width": 168, + "preference": -7, + "id": "30", + "resolution": "168x94" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg?sqp=-oaymwEbCMQBEG5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBUpEOOWUXWkNyijQuZ4UPzp2BE-w", + "height": 110, + "width": 196, + "preference": -7, + "id": "31", + "resolution": "196x110" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg?sqp=-oaymwEcCPYBEIoBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLBCyhr8AqpJ1SxKVU6SyK5ODJ_IpA", + "height": 138, + "width": 246, + "preference": -7, + "id": "32", + "resolution": "246x138" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLB_p0PncTtkrhaNDZtntrE3gKkoYw", + "height": 188, + "width": 336, + "preference": -7, + "id": "33", + "resolution": "336x188" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg", + "height": 360, + "width": 480, + "preference": -7, + "id": "34", + "resolution": "480x360" + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/hqdefault.webp", + "height": 360, + "width": 480, + "preference": -6, + "id": "35", + "resolution": "480x360" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/sddefault.jpg", + "height": 480, + "width": 640, + "preference": -5, + "id": "36", + "resolution": "640x480" + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/sddefault.webp", + "height": 480, + "width": 640, + "preference": -4, + "id": "37", + "resolution": "640x480" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hq720.jpg", + "preference": -3, + "id": "38" + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/hq720.webp", + "preference": -2, + "id": "39" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg", + "height": 720, + "width": 1280, + "preference": -1, + "id": "40", + "resolution": "1280x720" + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/maxresdefault.webp", + "height": 1080, + "width": 1920, + "preference": 0, + "id": "41", + "resolution": "1920x1080" + } + ], + "thumbnail": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/maxresdefault.webp", + "description": "The official video for \u201cNever Gonna Give You Up\u201d by Rick Astley\n\n\u2018Hold Me In Your Arms\u2019 \u2013 deluxe blue vinyl, 2CD and digital deluxe out 12th May 2023 Pre-order here \u2013 https://rick-astley.lnk.to/HMIYA2023ID\n\n\u201cNever Gonna Give You Up\u201d was a global smash on its release in July 1987, topping the charts in 25 countries including Rick\u2019s native UK and the US Billboard Hot 100. It also won the Brit Award for Best single in 1988. Stock Aitken and Waterman wrote and produced the track which was the lead-off single and lead track from Rick\u2019s debut LP \u201cWhenever You Need Somebody\u201d. The album was itself a UK number one and would go on to sell over 15 million copies worldwide.\n\nThe legendary video was directed by Simon West \u2013 who later went on to make Hollywood blockbusters such as Con Air, Lara Croft \u2013 Tomb Raider and The Expendables 2. The video passed the 1bn YouTube views milestone on 28 July 2021.\n\nSubscribe to the official Rick Astley YouTube channel: https://RickAstley.lnk.to/YTSubID\n\nFollow Rick Astley:\nFacebook: https://RickAstley.lnk.to/FBFollowID \nTwitter: https://RickAstley.lnk.to/TwitterID \nInstagram: https://RickAstley.lnk.to/InstagramID \nWebsite: https://RickAstley.lnk.to/storeID \nTikTok: https://RickAstley.lnk.to/TikTokID\n\nListen to Rick Astley:\nSpotify: https://RickAstley.lnk.to/SpotifyID \nApple Music: https://RickAstley.lnk.to/AppleMusicID \nAmazon Music: https://RickAstley.lnk.to/AmazonMusicID \nDeezer: https://RickAstley.lnk.to/DeezerID \n\nLyrics:\nWe\u2019re no strangers to love\nYou know the rules and so do I\nA full commitment\u2019s what I\u2019m thinking of\nYou wouldn\u2019t get this from any other guy\n\nI just wanna tell you how I\u2019m feeling\nGotta make you understand\n\nNever gonna give you up\nNever gonna let you down\nNever gonna run around and desert you\nNever gonna make you cry\nNever gonna say goodbye\nNever gonna tell a lie and hurt you\n\nWe\u2019ve known each other for so long\nYour heart\u2019s been aching but you\u2019re too shy to say it\nInside we both know what\u2019s been going on\nWe know the game and we\u2019re gonna play it\n\nAnd if you ask me how I\u2019m feeling\nDon\u2019t tell me you\u2019re too blind to see\n\nNever gonna give you up\nNever gonna let you down\nNever gonna run around and desert you\nNever gonna make you cry\nNever gonna say goodbye\nNever gonna tell a lie and hurt you\n\n#RickAstley #NeverGonnaGiveYouUp #WheneverYouNeedSomebody #OfficialMusicVideo", + "channel_id": "UCuAXFkgsw1L7xaCfnd5JJOw", + "channel_url": "https://www.youtube.com/channel/UCuAXFkgsw1L7xaCfnd5JJOw", + "duration": 212, + "view_count": 1447363306, + "average_rating": null, + "age_limit": 0, + "webpage_url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + "categories": ["Music"], + "tags": [ + "rick astley", + "Never Gonna Give You Up", + "nggyu", + "never gonna give you up lyrics", + "rick rolled", + "Rick Roll", + "rick astley official", + "rickrolled", + "Fortnite song", + "Fortnite event", + "Fortnite dance", + "fortnite never gonna give you up", + "rick roll", + "rickrolling", + "rick rolling", + "never gonna give you up", + "80s music", + "rick astley new", + "animated video", + "rickroll", + "meme songs", + "never gonna give u up lyrics", + "Rick Astley 2022", + "never gonna let you down", + "animated", + "rick rolls 2022", + "never gonna give you up karaoke" + ], + "playable_in_embed": true, + "live_status": "not_live", + "release_timestamp": null, + "_format_sort_fields": [ + "quality", + "res", + "fps", + "hdr:12", + "source", + "vcodec:vp9.2", + "channels", + "acodec", + "lang", + "proto" + ], + "automatic_captions": {}, + "subtitles": {}, + "comment_count": 2200000, + "chapters": null, + "heatmap": [], + "like_count": 16843103, + "channel": "Rick Astley", + "channel_follower_count": 3870000, + "channel_is_verified": true, + "uploader": "Rick Astley", + "uploader_id": "@RickAstleyYT", + "uploader_url": "https://www.youtube.com/@RickAstleyYT", + "upload_date": "20091025", + "availability": "public", + "original_url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + "webpage_url_basename": "watch", + "webpage_url_domain": "youtube.com", + "extractor": "youtube", + "extractor_key": "Youtube", + "playlist": null, + "playlist_index": null, + "display_id": "dQw4w9WgXcQ", + "fulltitle": "Rick Astley - Never Gonna Give You Up (Official Music Video)", + "duration_string": "3:32", + "is_live": false, + "was_live": false, + "requested_subtitles": null, + "_has_drm": null, + "epoch": 1694783695, + "requested_formats": [ + { + "format_id": "616", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805294/ei/zlgEZcCPFpqOx_APj42f2Ao/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/616/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D99471214%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D356%3Blmt%3D1694043438471036/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-aigzrnld/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/2095000/vprv/1/playlist_type/DVR/dover/13/txp/4532434/mt/1694783390/fvip/1/short_key/1/keepalive/yes/fexp/24007246,24362685/beids/24350017/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRgIhANCPwWNfq6wBp1Xo1L8bRJpDrzOyv7kfH_J65cZ_PRZLAiEAwo-0wQgeIjPe7OgyAAvMCx_A9wd1h8Qyh7VntKwGJUs%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIgIqS9Ub_6L9ScKXr0T9bkeu6TZsEsyNApYfF_MqeukqECIQCMSeJ1sSEw5QGMgHAW8Fhsir4TYHEK5KVg-PzJbrT6hw%3D%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805294/ei/zlgEZcCPFpqOx_APj42f2Ao/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-aigzrnld/ms/au%2Conr/mv/m/mvi/3/pl/22/tx/24388769/txs/24388767%2C24388768%2C24388769%2C24388770%2C24388771%2C24388772%2C24388773/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/2095000/vprv/1/go/1/mt/1694783390/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246%2C24362685/beids/24350017/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRAIfSO7YqwqqN5rvPVra8z_X7uhf3eONbgq6Wd7dUkRvQAIhAN_9UHWwCMbxaNIcBGwJcd2U7eGn8mcxLopjD2_obq4_/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRQIhAJsMzACAa3MAibS4ggmRWMpBoF5cEu2OPzA18PGS6JZiAiA0ooqsQhZJHwLRL5tKAUYrAUq_eY-wsx7nWV3SAiAbCw%3D%3D/file/index.m3u8", + "tbr": 5704.254, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 9, + "has_drm": false, + "width": 1920, + "height": 1080, + "vcodec": "vp09.00.40.08", + "acodec": "none", + "dynamic_range": "SDR", + "source_preference": 99, + "format_note": "Premium", + "resolution": "1920x1080", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 5704.254, + "format": "616 - 1920x1080 (Premium)" + }, + { + "asr": 48000, + "filesize": 3437753, + "format_id": "251", + "format_note": "medium", + "source_preference": -1, + "fps": null, + "audio_channels": 2, + "height": null, + "quality": 3.0, + "has_drm": false, + "tbr": 129.689, + "url": "https://rr2---sn-5hnekn7k.googlevideo.com/videoplayback?expire=1694805294&ei=zlgEZaLeHcrlgAeFhLrYBA&ip=45.93.75.130&id=o-AFIa6Sil61_wuEFkUVhjKkr-0pyzj2cHi52leur2vR1j&itag=251&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hnekn7k%2Csn-5hne6nzy&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=2095000&spc=UWF9f2Ob7Uhbkv1q69SZBYEqtijLGjs&vprv=1&svpuc=1&mime=audio%2Fwebm&gir=yes&clen=3437753&dur=212.061&lmt=1694040798752663&mt=1694783390&fvip=3&keepalive=yes&fexp=24007246%2C24362685&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhAK3VvF4KH-4nYQUP1gpSURVLxA9j_1qSnMFHt4a8Stk8AiEA8wi7_ubVv4HzCGjW_pWZaUBRNXJaQ-1GuAAJovlF_E8%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAI4QpoB0iBj-oMiNFMMdN0RN-u3nLji437a3jqTbhncSAiEAlvsdhJjG0-VZ2jCjyUZBtidBcUzYFwnk6qG7mIiNjCA%3D", + "width": null, + "language": "en", + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "none", + "acodec": "opus", + "dynamic_range": null, + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "audio only", + "aspect_ratio": null, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "webm", + "video_ext": "none", + "vbr": 0, + "abr": 129.689, + "format": "251 - audio only (medium)" + } + ], + "format": "616 - 1920x1080 (Premium)+251 - audio only (medium)", + "format_id": "616+251", + "ext": "webm", + "protocol": "m3u8_native+https", + "language": "en", + "format_note": "Premium+medium", + "filesize_approx": 3437753, + "tbr": 5833.943, + "width": 1920, + "height": 1080, + "resolution": "1920x1080", + "fps": 25.0, + "dynamic_range": "SDR", + "vcodec": "vp09.00.40.08", + "vbr": 5704.254, + "stretched_ratio": null, + "aspect_ratio": 1.78, + "acodec": "opus", + "abr": 129.689, + "asr": 48000, + "audio_channels": 2 +} diff --git a/tests/components/media_extractor/fixtures/youtube_1_result_bestaudio.json b/tests/components/media_extractor/fixtures/youtube_1_result_bestaudio.json new file mode 100644 index 00000000000..308b43a39f9 --- /dev/null +++ b/tests/components/media_extractor/fixtures/youtube_1_result_bestaudio.json @@ -0,0 +1,2264 @@ +{ + "id": "dQw4w9WgXcQ", + "title": "Rick Astley - Never Gonna Give You Up (Official Music Video)", + "formats": [ + { + "format_id": "sb2", + "format_note": "storyboard", + "ext": "mhtml", + "protocol": "mhtml", + "acodec": "none", + "vcodec": "none", + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L0/default.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCSw4ypjBBVyVfNU-jl-4aLZArqkA", + "width": 48, + "height": 27, + "fps": 0.4716981132075472, + "rows": 10, + "columns": 10, + "fragments": [ + { + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L0/default.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCSw4ypjBBVyVfNU-jl-4aLZArqkA", + "duration": 212.0 + } + ], + "resolution": "48x27", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "none", + "video_ext": "none", + "vbr": 0, + "abr": 0, + "tbr": null, + "format": "sb2 - 48x27 (storyboard)" + }, + { + "format_id": "sb1", + "format_note": "storyboard", + "ext": "mhtml", + "protocol": "mhtml", + "acodec": "none", + "vcodec": "none", + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L1/M$M.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCTXAc1RH5-ZIGm3FlRiYRUkzQXug", + "width": 80, + "height": 45, + "fps": 0.5094339622641509, + "rows": 10, + "columns": 10, + "fragments": [ + { + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L1/M0.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCTXAc1RH5-ZIGm3FlRiYRUkzQXug", + "duration": 196.29629629629628 + }, + { + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L1/M1.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCTXAc1RH5-ZIGm3FlRiYRUkzQXug", + "duration": 15.703703703703724 + } + ], + "resolution": "80x45", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "none", + "video_ext": "none", + "vbr": 0, + "abr": 0, + "tbr": null, + "format": "sb1 - 80x45 (storyboard)" + }, + { + "format_id": "sb0", + "format_note": "storyboard", + "ext": "mhtml", + "protocol": "mhtml", + "acodec": "none", + "vcodec": "none", + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L2/M$M.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCHYrXWHttYu2902drAXv1Wg3kN4g", + "width": 160, + "height": 90, + "fps": 0.5094339622641509, + "rows": 5, + "columns": 5, + "fragments": [ + { + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L2/M0.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCHYrXWHttYu2902drAXv1Wg3kN4g", + "duration": 49.07407407407407 + }, + { + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L2/M1.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCHYrXWHttYu2902drAXv1Wg3kN4g", + "duration": 49.07407407407407 + }, + { + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L2/M2.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCHYrXWHttYu2902drAXv1Wg3kN4g", + "duration": 49.07407407407407 + }, + { + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L2/M3.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCHYrXWHttYu2902drAXv1Wg3kN4g", + "duration": 49.07407407407407 + }, + { + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L2/M4.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCHYrXWHttYu2902drAXv1Wg3kN4g", + "duration": 15.703703703703724 + } + ], + "resolution": "160x90", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "none", + "video_ext": "none", + "vbr": 0, + "abr": 0, + "tbr": null, + "format": "sb0 - 160x90 (storyboard)" + }, + { + "format_id": "233", + "format_note": "Default", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805268/ei/tFgEZcu0DoOD-gaqg47wBA/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/233/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/goi/133/sgoap/clen%3D1294944%3Bdur%3D212.183%3Bgir%3Dyes%3Bitag%3D139%3Blmt%3D1694042119353699/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,29/mn/sn-5hne6nzy,sn-5hnekn7k/ms/au,rdu/mv/m/mvi/3/pl/22/initcwndbps/1957500/vprv/1/playlist_type/DVR/dover/13/txp/4532434/mt/1694783146/fvip/2/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,goi,sgoap,vprv,playlist_type/sig/AOq0QJ8wRQIgXkd8x1p9qzd2j33lOyZV42nErv3V7DI_c6VxUO81MicCIQDYoX5ygRN4QPASau7kQ7iyPd1CcP4gFR4ocpVOu7Ql4w%3D%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRAIgNW-mInu4qMdbNLCOxR-yh-5-tb2-tl27vi7PeWp_TN0CIF-Q7eFVTvhGvlyPGQIC-2pFLPkr8AELeobAL63PQB4I/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805268/ei/tFgEZcu0DoOD-gaqg47wBA/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C29/mn/sn-5hne6nzy%2Csn-5hnekn7k/ms/au%2Crdu/mv/m/mvi/3/pl/22/tx/24388770/txs/24388767%2C24388768%2C24388769%2C24388770%2C24388771%2C24388772%2C24388773/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1957500/vprv/1/go/1/mt/1694783146/fvip/2/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRQIhAL8hNlWRffc33Ibzb-OCH34lon8WNpNiKHeUFUFMzvlDAiAr5e33CzsKEX8k0MiF68H_7xmDW2b6HQSW0uiiFr2Rxg%3D%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRAIgF4H6KcG8d2n_e4oe9m5iMfJFj7-zvjFtzJfWCByyfVwCIDLMnTkaV3Szw249SnaNqBAw5vMO_DCwPJZkqzdT0P5p/file/index.m3u8", + "language": "en", + "ext": "mp4", + "protocol": "m3u8_native", + "preference": null, + "quality": -1, + "has_drm": false, + "vcodec": "none", + "source_preference": -1, + "resolution": "audio only", + "aspect_ratio": null, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "mp4", + "video_ext": "none", + "vbr": 0, + "abr": null, + "tbr": null, + "format": "233 - audio only (Default)" + }, + { + "format_id": "234", + "format_note": "Default", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805268/ei/tFgEZcu0DoOD-gaqg47wBA/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/234/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/goi/133/sgoap/clen%3D3433514%3Bdur%3D212.091%3Bgir%3Dyes%3Bitag%3D140%3Blmt%3D1694042124987733/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,29/mn/sn-5hne6nzy,sn-5hnekn7k/ms/au,rdu/mv/m/mvi/3/pl/22/initcwndbps/1957500/vprv/1/playlist_type/DVR/dover/13/txp/4532434/mt/1694783146/fvip/2/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,goi,sgoap,vprv,playlist_type/sig/AOq0QJ8wRQIgZa77CyKyBzfX0ygVmHFcsZUv3yC-RJ0VQJzii3TWHxUCIQDo74AX1uTaERaFpGWfrIkbhxcvmV6SIAS_ix9VOQEGzw%3D%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIhAPHYcs-8I7Ze2T23fJk6MCHoiAG_5Tu2YX03KS42YFmEAiB7ILkCUEzIXLA9IdxSOWv9apPOQz_3pIfG7-F0QE2mBQ%3D%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805268/ei/tFgEZcu0DoOD-gaqg47wBA/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C29/mn/sn-5hne6nzy%2Csn-5hnekn7k/ms/au%2Crdu/mv/m/mvi/3/pl/22/tx/24388770/txs/24388767%2C24388768%2C24388769%2C24388770%2C24388771%2C24388772%2C24388773/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1957500/vprv/1/go/1/mt/1694783146/fvip/2/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRQIhAL8hNlWRffc33Ibzb-OCH34lon8WNpNiKHeUFUFMzvlDAiAr5e33CzsKEX8k0MiF68H_7xmDW2b6HQSW0uiiFr2Rxg%3D%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRAIgF4H6KcG8d2n_e4oe9m5iMfJFj7-zvjFtzJfWCByyfVwCIDLMnTkaV3Szw249SnaNqBAw5vMO_DCwPJZkqzdT0P5p/file/index.m3u8", + "language": "en", + "ext": "mp4", + "protocol": "m3u8_native", + "preference": null, + "quality": -1, + "has_drm": false, + "vcodec": "none", + "source_preference": -1, + "resolution": "audio only", + "aspect_ratio": null, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "mp4", + "video_ext": "none", + "vbr": 0, + "abr": null, + "tbr": null, + "format": "234 - audio only (Default)" + }, + { + "asr": 22050, + "filesize": 817805, + "format_id": "599", + "format_note": "ultralow", + "source_preference": -1, + "fps": null, + "audio_channels": 2, + "height": null, + "quality": 1.0, + "has_drm": false, + "tbr": 30.833, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805268&ei=tFgEZaHmFN2Px_AP2tSt2AQ&ip=45.93.75.130&id=o-AEj4DudORoGviGzjggo2mjXrQpjRh8L2BrOU-wekY859&itag=599&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hne6nzy%2Csn-5hnekn7k&ms=au%2Crdu&mv=m&mvi=3&pl=22&initcwndbps=1957500&spc=UWF9f7_CV3gS4VV2VFq7hgxtUAyOlog&vprv=1&svpuc=1&mime=audio%2Fmp4&gir=yes&clen=817805&dur=212.183&lmt=1694040788792847&mt=1694783146&fvip=2&keepalive=yes&fexp=24007246&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhAI_bE9DSOHyCMgNfFt9nrATs4DRujKS4YucEj5nO29irAiEA5e524FKJkawf7eCdZSzkVYNyejS2CfDMUCZKYMlO0x8%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIgRCGi20K-ZvdukYkBZOidcHpGPUpIBOkw-jZGEncsKQECIQC5h-rCfQhDTQFqocOTtQXcNZVA54oIqjweF0mN5GpzFA%3D%3D", + "width": null, + "language": "en", + "language_preference": -1, + "preference": null, + "ext": "m4a", + "vcodec": "none", + "acodec": "mp4a.40.5", + "dynamic_range": null, + "container": "m4a_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "audio only", + "aspect_ratio": null, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "m4a", + "video_ext": "none", + "vbr": 0, + "abr": 30.833, + "format": "599 - audio only (ultralow)" + }, + { + "asr": 48000, + "filesize": 832823, + "format_id": "600", + "format_note": "ultralow", + "source_preference": -1, + "fps": null, + "audio_channels": 2, + "height": null, + "quality": 1.0, + "has_drm": false, + "tbr": 31.418, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805268&ei=tFgEZaHmFN2Px_AP2tSt2AQ&ip=45.93.75.130&id=o-AEj4DudORoGviGzjggo2mjXrQpjRh8L2BrOU-wekY859&itag=600&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hne6nzy%2Csn-5hnekn7k&ms=au%2Crdu&mv=m&mvi=3&pl=22&initcwndbps=1957500&spc=UWF9f7_CV3gS4VV2VFq7hgxtUAyOlog&vprv=1&svpuc=1&mime=audio%2Fwebm&gir=yes&clen=832823&dur=212.061&lmt=1694040798740210&mt=1694783146&fvip=2&keepalive=yes&fexp=24007246&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRAIgYs6oKSX5FlxqbWOfmux7P0JUBvOMbRhAa470efqLTkQCIC-tHzjH0-uR_Os8IQmsaQqF3L1jIqGUo3LwEdaYZCAR&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIgRCGi20K-ZvdukYkBZOidcHpGPUpIBOkw-jZGEncsKQECIQC5h-rCfQhDTQFqocOTtQXcNZVA54oIqjweF0mN5GpzFA%3D%3D", + "width": null, + "language": "en", + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "none", + "acodec": "opus", + "dynamic_range": null, + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "audio only", + "aspect_ratio": null, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "webm", + "video_ext": "none", + "vbr": 0, + "abr": 31.418, + "format": "600 - audio only (ultralow)" + }, + { + "asr": 22050, + "filesize": 1294944, + "format_id": "139", + "format_note": "low", + "source_preference": -1, + "fps": null, + "audio_channels": 2, + "height": null, + "quality": 2.0, + "has_drm": false, + "tbr": 48.823, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805268&ei=tFgEZcu0DoOD-gaqg47wBA&ip=45.93.75.130&id=o-ALADwM6dkuCPsPIQiQ_ygvtMcP-xvew7ntgwcwtzWc4N&itag=139&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hne6nzy%2Csn-5hnekn7k&ms=au%2Crdu&mv=m&mvi=3&pl=22&initcwndbps=1957500&vprv=1&svpuc=1&mime=audio%2Fmp4&gir=yes&clen=1294944&dur=212.183&lmt=1694042119353699&mt=1694783146&fvip=2&keepalive=yes&fexp=24007246&c=IOS&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIgeL1-yrjqVMYmj-HE7FzWxzRtzXt9NLSDRf-wpE4R_1wCIQDMyLhuFZDSpGx5VPddUhnd4G3dlT5ptmwD0pZVP1gEVA%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgHX4-RXGLMMOGBkRk1sGy7XnQ3wkahwF60RoxGmOabF0CIBpQjZOMeQQeqZX8JccDZAypFCP3chfxrtgzsfWCJJ0l", + "width": null, + "language": "en", + "language_preference": -1, + "preference": null, + "ext": "m4a", + "vcodec": "none", + "acodec": "mp4a.40.5", + "dynamic_range": null, + "container": "m4a_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "audio only", + "aspect_ratio": null, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "m4a", + "video_ext": "none", + "vbr": 0, + "abr": 48.823, + "format": "139 - audio only (low)" + }, + { + "asr": 48000, + "filesize": 1232413, + "format_id": "249", + "format_note": "low", + "source_preference": -1, + "fps": null, + "audio_channels": 2, + "height": null, + "quality": 2.0, + "has_drm": false, + "tbr": 46.492, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805268&ei=tFgEZaHmFN2Px_AP2tSt2AQ&ip=45.93.75.130&id=o-AEj4DudORoGviGzjggo2mjXrQpjRh8L2BrOU-wekY859&itag=249&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hne6nzy%2Csn-5hnekn7k&ms=au%2Crdu&mv=m&mvi=3&pl=22&initcwndbps=1957500&spc=UWF9f7_CV3gS4VV2VFq7hgxtUAyOlog&vprv=1&svpuc=1&mime=audio%2Fwebm&gir=yes&clen=1232413&dur=212.061&lmt=1694040798737498&mt=1694783146&fvip=2&keepalive=yes&fexp=24007246&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIhAMm5hvk9HKKir9EzGVZNYX1OXgO80mxX9DsKa4UhzBWIAiB8xQEAMD6v9gm_IcXr6OKMMQyEr6SJR2zEpaF165ZyUA%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIgRCGi20K-ZvdukYkBZOidcHpGPUpIBOkw-jZGEncsKQECIQC5h-rCfQhDTQFqocOTtQXcNZVA54oIqjweF0mN5GpzFA%3D%3D", + "width": null, + "language": "en", + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "none", + "acodec": "opus", + "dynamic_range": null, + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "audio only", + "aspect_ratio": null, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "webm", + "video_ext": "none", + "vbr": 0, + "abr": 46.492, + "format": "249 - audio only (low)" + }, + { + "asr": 48000, + "filesize": 1630086, + "format_id": "250", + "format_note": "low", + "source_preference": -1, + "fps": null, + "audio_channels": 2, + "height": null, + "quality": 2.0, + "has_drm": false, + "tbr": 61.494, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805268&ei=tFgEZaHmFN2Px_AP2tSt2AQ&ip=45.93.75.130&id=o-AEj4DudORoGviGzjggo2mjXrQpjRh8L2BrOU-wekY859&itag=250&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hne6nzy%2Csn-5hnekn7k&ms=au%2Crdu&mv=m&mvi=3&pl=22&initcwndbps=1957500&spc=UWF9f7_CV3gS4VV2VFq7hgxtUAyOlog&vprv=1&svpuc=1&mime=audio%2Fwebm&gir=yes&clen=1630086&dur=212.061&lmt=1694040798724510&mt=1694783146&fvip=2&keepalive=yes&fexp=24007246&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIhAPmH9rWSy2FgeIuFNGJwypYrWir9Swzj7paOT3H361mDAiBhx0X3OUVatDSWXUoxO6givpL61YWs44SqnQ9JEcRhbA%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIgRCGi20K-ZvdukYkBZOidcHpGPUpIBOkw-jZGEncsKQECIQC5h-rCfQhDTQFqocOTtQXcNZVA54oIqjweF0mN5GpzFA%3D%3D", + "width": null, + "language": "en", + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "none", + "acodec": "opus", + "dynamic_range": null, + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "audio only", + "aspect_ratio": null, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "webm", + "video_ext": "none", + "vbr": 0, + "abr": 61.494, + "format": "250 - audio only (low)" + }, + { + "asr": 44100, + "filesize": 3433514, + "format_id": "140", + "format_note": "medium", + "source_preference": -1, + "fps": null, + "audio_channels": 2, + "height": null, + "quality": 3.0, + "has_drm": false, + "tbr": 129.51, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805268&ei=tFgEZcu0DoOD-gaqg47wBA&ip=45.93.75.130&id=o-ALADwM6dkuCPsPIQiQ_ygvtMcP-xvew7ntgwcwtzWc4N&itag=140&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hne6nzy%2Csn-5hnekn7k&ms=au%2Crdu&mv=m&mvi=3&pl=22&initcwndbps=1957500&vprv=1&svpuc=1&mime=audio%2Fmp4&gir=yes&clen=3433514&dur=212.091&lmt=1694042124987733&mt=1694783146&fvip=2&keepalive=yes&fexp=24007246&c=IOS&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIhAN2RErItVbu3k8nIfPO8NcCKxwLL0uX_GFXP0VpbMR5vAiBEWlvWa-nDlR6wmTefzQlGaM5FaDqHQh9Pm5aQ-etaqg%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgHX4-RXGLMMOGBkRk1sGy7XnQ3wkahwF60RoxGmOabF0CIBpQjZOMeQQeqZX8JccDZAypFCP3chfxrtgzsfWCJJ0l", + "width": null, + "language": "en", + "language_preference": -1, + "preference": null, + "ext": "m4a", + "vcodec": "none", + "acodec": "mp4a.40.2", + "dynamic_range": null, + "container": "m4a_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "audio only", + "aspect_ratio": null, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "m4a", + "video_ext": "none", + "vbr": 0, + "abr": 129.51, + "format": "140 - audio only (medium)" + }, + { + "asr": 48000, + "filesize": 3437753, + "format_id": "251", + "format_note": "medium", + "source_preference": -1, + "fps": null, + "audio_channels": 2, + "height": null, + "quality": 3.0, + "has_drm": false, + "tbr": 129.689, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805268&ei=tFgEZaHmFN2Px_AP2tSt2AQ&ip=45.93.75.130&id=o-AEj4DudORoGviGzjggo2mjXrQpjRh8L2BrOU-wekY859&itag=251&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hne6nzy%2Csn-5hnekn7k&ms=au%2Crdu&mv=m&mvi=3&pl=22&initcwndbps=1957500&spc=UWF9f7_CV3gS4VV2VFq7hgxtUAyOlog&vprv=1&svpuc=1&mime=audio%2Fwebm&gir=yes&clen=3437753&dur=212.061&lmt=1694040798752663&mt=1694783146&fvip=2&keepalive=yes&fexp=24007246&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhAL5gfx40AmiGAp0vSd50hypQlBE4W-Qo5iiD95oYKtH-AiEAybTs2BuunUUrhfMdyEcuPxwPC8ww_-p-danCp9uAArc%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIgRCGi20K-ZvdukYkBZOidcHpGPUpIBOkw-jZGEncsKQECIQC5h-rCfQhDTQFqocOTtQXcNZVA54oIqjweF0mN5GpzFA%3D%3D", + "width": null, + "language": "en", + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "none", + "acodec": "opus", + "dynamic_range": null, + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "audio only", + "aspect_ratio": null, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "webm", + "video_ext": "none", + "vbr": 0, + "abr": 129.689, + "format": "251 - audio only (medium)" + }, + { + "asr": 22050, + "filesize": 2086732, + "format_id": "17", + "format_note": "144p", + "source_preference": -1, + "fps": 6, + "audio_channels": 1, + "height": 144, + "quality": 0.0, + "has_drm": false, + "tbr": 78.693, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805268&ei=tFgEZaHmFN2Px_AP2tSt2AQ&ip=45.93.75.130&id=o-AEj4DudORoGviGzjggo2mjXrQpjRh8L2BrOU-wekY859&itag=17&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hne6nzy%2Csn-5hnekn7k&ms=au%2Crdu&mv=m&mvi=3&pl=22&initcwndbps=1957500&spc=UWF9f7_CV3gS4VV2VFq7hgxtUAyOlog&vprv=1&svpuc=1&mime=video%2F3gpp&gir=yes&clen=2086732&dur=212.137&lmt=1694042486266781&mt=1694783146&fvip=2&fexp=24007246&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIgXc6jqpr0Okm6Xrpv9kwH0gYRdS7d8reJudfbSscQG64CIQDvpzrhNmE47BajCckrEUi2oezc7t9QBW1ntvC8JRYgtA%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIgRCGi20K-ZvdukYkBZOidcHpGPUpIBOkw-jZGEncsKQECIQC5h-rCfQhDTQFqocOTtQXcNZVA54oIqjweF0mN5GpzFA%3D%3D", + "width": 176, + "language": "en", + "language_preference": -1, + "preference": -2, + "ext": "3gp", + "vcodec": "mp4v.20.3", + "acodec": "mp4a.40.2", + "dynamic_range": "SDR", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "176x144", + "aspect_ratio": 1.22, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "3gp", + "audio_ext": "none", + "vbr": null, + "abr": null, + "format": "17 - 176x144 (144p)" + }, + { + "asr": null, + "filesize": 847252, + "format_id": "597", + "format_note": "144p", + "source_preference": -1, + "fps": 13, + "audio_channels": null, + "height": 144, + "quality": 0.0, + "has_drm": false, + "tbr": 31.959, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805268&ei=tFgEZaHmFN2Px_AP2tSt2AQ&ip=45.93.75.130&id=o-AEj4DudORoGviGzjggo2mjXrQpjRh8L2BrOU-wekY859&itag=597&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hne6nzy%2Csn-5hnekn7k&ms=au%2Crdu&mv=m&mvi=3&pl=22&initcwndbps=1957500&spc=UWF9f7_CV3gS4VV2VFq7hgxtUAyOlog&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=847252&dur=212.080&lmt=1694042194934376&mt=1694783146&fvip=2&keepalive=yes&fexp=24007246&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRAIgfjO4rNa_oBrurVMhq66M99RPPwm45aYzSWXz4V53Ot0CIENxgQXCDHqdjTXtO9NT_gU1sbTYA5rDJ6SQjAowt0De&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIgRCGi20K-ZvdukYkBZOidcHpGPUpIBOkw-jZGEncsKQECIQC5h-rCfQhDTQFqocOTtQXcNZVA54oIqjweF0mN5GpzFA%3D%3D", + "width": 256, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "avc1.4d400b", + "acodec": "none", + "dynamic_range": "SDR", + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "256x144", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 31.959, + "format": "597 - 256x144 (144p)" + }, + { + "format_id": "602", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805268/ei/tFgEZcu0DoOD-gaqg47wBA/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/602/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D643306%3Bdur%3D212.080%3Bgir%3Dyes%3Bitag%3D598%3Blmt%3D1694042224218554/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,29/mn/sn-5hne6nzy,sn-5hnekn7k/ms/au,rdu/mv/m/mvi/3/pl/22/initcwndbps/1957500/vprv/1/playlist_type/DVR/dover/13/txp/4532434/mt/1694783146/fvip/2/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRQIgcAnEndUr_xkLfgS0SnKnb4heEtzfOurEKvglIUDG64ECIQC7iIqUdvT_ooEV-rLA2Q2BOyaUCkEvvP6eGT0Hqnu73w%3D%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIgVqi7Sv_zMX3Fd182Lkxf8pBBsZl5S5qignj5KA7Ung0CIQCWclCKYj00R_QS-mciAKxsTh2CjaXueF4Q7MjdlQeMaQ%3D%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805268/ei/tFgEZcu0DoOD-gaqg47wBA/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C29/mn/sn-5hne6nzy%2Csn-5hnekn7k/ms/au%2Crdu/mv/m/mvi/3/pl/22/tx/24388770/txs/24388767%2C24388768%2C24388769%2C24388770%2C24388771%2C24388772%2C24388773/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1957500/vprv/1/go/1/mt/1694783146/fvip/2/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRQIhAL8hNlWRffc33Ibzb-OCH34lon8WNpNiKHeUFUFMzvlDAiAr5e33CzsKEX8k0MiF68H_7xmDW2b6HQSW0uiiFr2Rxg%3D%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRAIgF4H6KcG8d2n_e4oe9m5iMfJFj7-zvjFtzJfWCByyfVwCIDLMnTkaV3Szw249SnaNqBAw5vMO_DCwPJZkqzdT0P5p/file/index.m3u8", + "tbr": 80.559, + "ext": "mp4", + "fps": 13.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 0, + "has_drm": false, + "width": 256, + "height": 144, + "vcodec": "vp09.00.10.08", + "acodec": "none", + "dynamic_range": "SDR", + "source_preference": -1, + "resolution": "256x144", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 80.559, + "format": "602 - 256x144" + }, + { + "asr": null, + "filesize": 643306, + "format_id": "598", + "format_note": "144p", + "source_preference": -1, + "fps": 13, + "audio_channels": null, + "height": 144, + "quality": 0.0, + "has_drm": false, + "tbr": 24.266, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805268&ei=tFgEZaHmFN2Px_AP2tSt2AQ&ip=45.93.75.130&id=o-AEj4DudORoGviGzjggo2mjXrQpjRh8L2BrOU-wekY859&itag=598&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hne6nzy%2Csn-5hnekn7k&ms=au%2Crdu&mv=m&mvi=3&pl=22&initcwndbps=1957500&spc=UWF9f7_CV3gS4VV2VFq7hgxtUAyOlog&vprv=1&svpuc=1&mime=video%2Fwebm&gir=yes&clen=643306&dur=212.080&lmt=1694042224218554&mt=1694783146&fvip=2&keepalive=yes&fexp=24007246&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIhAK9n1La19b0lg9KpcV6H-jtXpX52S07qiX2TkafUCKHCAiBcBBRjAxYASJWWpEe8GIEVfeUrhvP4DMVz-JpY1NES1Q%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIgRCGi20K-ZvdukYkBZOidcHpGPUpIBOkw-jZGEncsKQECIQC5h-rCfQhDTQFqocOTtQXcNZVA54oIqjweF0mN5GpzFA%3D%3D", + "width": 256, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "vp9", + "acodec": "none", + "dynamic_range": "SDR", + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "256x144", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "webm", + "audio_ext": "none", + "abr": 0, + "vbr": 24.266, + "format": "598 - 256x144 (144p)" + }, + { + "asr": null, + "filesize": 1416915, + "format_id": "394", + "format_note": "144p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 144, + "quality": 0.0, + "has_drm": false, + "tbr": 53.458, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805268&ei=tFgEZaHmFN2Px_AP2tSt2AQ&ip=45.93.75.130&id=o-AEj4DudORoGviGzjggo2mjXrQpjRh8L2BrOU-wekY859&itag=394&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hne6nzy%2Csn-5hnekn7k&ms=au%2Crdu&mv=m&mvi=3&pl=22&initcwndbps=1957500&spc=UWF9f7_CV3gS4VV2VFq7hgxtUAyOlog&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=1416915&dur=212.040&lmt=1694042192787352&mt=1694783146&fvip=2&keepalive=yes&fexp=24007246&beids=24350018&c=ANDROID&txp=4537434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIhALFZcl6qpHntlTCo_m-ouchCCib6GX7tngoF4X2Iyfd3AiBSd9xw6SXXlQyUrHEJbxkTrfpF9ubFg6KJAuFwP9f3xw%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIgRCGi20K-ZvdukYkBZOidcHpGPUpIBOkw-jZGEncsKQECIQC5h-rCfQhDTQFqocOTtQXcNZVA54oIqjweF0mN5GpzFA%3D%3D", + "width": 256, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "av01.0.00M.08", + "acodec": "none", + "dynamic_range": "SDR", + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "256x144", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 53.458, + "format": "394 - 256x144 (144p)" + }, + { + "format_id": "269", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805268/ei/tFgEZcu0DoOD-gaqg47wBA/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/269/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/sgovp/clen%3D1863601%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D160%3Blmt%3D1694045032286738/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,29/mn/sn-5hne6nzy,sn-5hnekn7k/ms/au,rdu/mv/m/mvi/3/pl/22/initcwndbps/1957500/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1694783146/fvip/2/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,sgovp,vprv,playlist_type/sig/AOq0QJ8wRAIgQCS6upmhOwB8qDvvR7rW5cE3xyeXjpHQIiAX6lx9oKYCIDyXRvU1Iu5LIWFCQ6QFmJ78UIyVuV_YUJF-jfok5qir/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIhAI_RZhf1Ru3O5EKLZ2Z-qjhMffd1sRFHgulaQHiNefu0AiB4xHj4OmYIMrue0LZKOa_rJm3e9bmYMNwGAi8dfw06pQ%3D%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805268/ei/tFgEZcu0DoOD-gaqg47wBA/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C29/mn/sn-5hne6nzy%2Csn-5hnekn7k/ms/au%2Crdu/mv/m/mvi/3/pl/22/tx/24388770/txs/24388767%2C24388768%2C24388769%2C24388770%2C24388771%2C24388772%2C24388773/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1957500/vprv/1/go/1/mt/1694783146/fvip/2/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRQIhAL8hNlWRffc33Ibzb-OCH34lon8WNpNiKHeUFUFMzvlDAiAr5e33CzsKEX8k0MiF68H_7xmDW2b6HQSW0uiiFr2Rxg%3D%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRAIgF4H6KcG8d2n_e4oe9m5iMfJFj7-zvjFtzJfWCByyfVwCIDLMnTkaV3Szw249SnaNqBAw5vMO_DCwPJZkqzdT0P5p/file/index.m3u8", + "tbr": 156.229, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 0, + "has_drm": false, + "width": 256, + "height": 144, + "vcodec": "avc1.4D400C", + "acodec": "none", + "dynamic_range": "SDR", + "source_preference": -1, + "resolution": "256x144", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 156.229, + "format": "269 - 256x144" + }, + { + "asr": null, + "filesize": 1863601, + "format_id": "160", + "format_note": "144p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 144, + "quality": 0.0, + "has_drm": false, + "tbr": 70.311, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805268&ei=tFgEZcu0DoOD-gaqg47wBA&ip=45.93.75.130&id=o-ALADwM6dkuCPsPIQiQ_ygvtMcP-xvew7ntgwcwtzWc4N&itag=160&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hne6nzy%2Csn-5hnekn7k&ms=au%2Crdu&mv=m&mvi=3&pl=22&initcwndbps=1957500&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=1863601&dur=212.040&lmt=1694045032286738&mt=1694783146&fvip=2&keepalive=yes&fexp=24007246&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIgNaaek385L4ctiA9PSdp7ZmdpudYVaOrZnDit2OykRPkCIQC8O31-81F2J2tZytFdstt3BqRAAtNXPAvXOkLYeYBeYw%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgHX4-RXGLMMOGBkRk1sGy7XnQ3wkahwF60RoxGmOabF0CIBpQjZOMeQQeqZX8JccDZAypFCP3chfxrtgzsfWCJJ0l", + "width": 256, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "avc1.4D400C", + "acodec": "none", + "dynamic_range": "SDR", + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "256x144", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 70.311, + "format": "160 - 256x144 (144p)" + }, + { + "format_id": "603", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805268/ei/tFgEZcu0DoOD-gaqg47wBA/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/603/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D2404581%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D278%3Blmt%3D1694043382822868/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,29/mn/sn-5hne6nzy,sn-5hnekn7k/ms/au,rdu/mv/m/mvi/3/pl/22/initcwndbps/1957500/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1694783146/fvip/2/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRQIhALQTWi8p3XMu1h-eOUdXyc00hPdYv78OHDEQi8uxYnXMAiBcoHLH7IEnjRymhqAat1tG5-YnxV-9ye3V7KDJtUJMPQ%3D%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIgOxESKety9F2fZ5r8O43_gUR9xJ_yjzbQ2CBQIlgps0oCIQC4ArPaIpjt6J7qTJOmUggLPuiyPHRv474nKCjT1AiOFA%3D%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805268/ei/tFgEZcu0DoOD-gaqg47wBA/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C29/mn/sn-5hne6nzy%2Csn-5hnekn7k/ms/au%2Crdu/mv/m/mvi/3/pl/22/tx/24388770/txs/24388767%2C24388768%2C24388769%2C24388770%2C24388771%2C24388772%2C24388773/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1957500/vprv/1/go/1/mt/1694783146/fvip/2/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRQIhAL8hNlWRffc33Ibzb-OCH34lon8WNpNiKHeUFUFMzvlDAiAr5e33CzsKEX8k0MiF68H_7xmDW2b6HQSW0uiiFr2Rxg%3D%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRAIgF4H6KcG8d2n_e4oe9m5iMfJFj7-zvjFtzJfWCByyfVwCIDLMnTkaV3Szw249SnaNqBAw5vMO_DCwPJZkqzdT0P5p/file/index.m3u8", + "tbr": 153.593, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 0, + "has_drm": false, + "width": 256, + "height": 144, + "vcodec": "vp09.00.11.08", + "acodec": "none", + "dynamic_range": "SDR", + "source_preference": -1, + "resolution": "256x144", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 153.593, + "format": "603 - 256x144" + }, + { + "asr": null, + "filesize": 2404581, + "format_id": "278", + "format_note": "144p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 144, + "quality": 0.0, + "has_drm": false, + "tbr": 90.721, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805268&ei=tFgEZcu0DoOD-gaqg47wBA&ip=45.93.75.130&id=o-ALADwM6dkuCPsPIQiQ_ygvtMcP-xvew7ntgwcwtzWc4N&itag=278&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hne6nzy%2Csn-5hnekn7k&ms=au%2Crdu&mv=m&mvi=3&pl=22&initcwndbps=1957500&vprv=1&svpuc=1&mime=video%2Fwebm&gir=yes&clen=2404581&dur=212.040&lmt=1694043382822868&mt=1694783146&fvip=2&keepalive=yes&fexp=24007246&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIhAIT3oQ5Em_Qkt76EC-ig_mB-5D7ubDgUX8CEtnsWqmsAAiAIFzLFgVdGphU__C9zMbNXxmxC2cubwG7AbWB_WsojiA%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgHX4-RXGLMMOGBkRk1sGy7XnQ3wkahwF60RoxGmOabF0CIBpQjZOMeQQeqZX8JccDZAypFCP3chfxrtgzsfWCJJ0l", + "width": 256, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "vp09.00.11.08", + "acodec": "none", + "dynamic_range": "SDR", + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "256x144", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "webm", + "audio_ext": "none", + "abr": 0, + "vbr": 90.721, + "format": "278 - 256x144 (144p)" + }, + { + "asr": null, + "filesize": 3024455, + "format_id": "395", + "format_note": "240p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 240, + "quality": 5.0, + "has_drm": false, + "tbr": 114.108, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805268&ei=tFgEZaHmFN2Px_AP2tSt2AQ&ip=45.93.75.130&id=o-AEj4DudORoGviGzjggo2mjXrQpjRh8L2BrOU-wekY859&itag=395&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hne6nzy%2Csn-5hnekn7k&ms=au%2Crdu&mv=m&mvi=3&pl=22&initcwndbps=1957500&spc=UWF9f7_CV3gS4VV2VFq7hgxtUAyOlog&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=3024455&dur=212.040&lmt=1694042297309821&mt=1694783146&fvip=2&keepalive=yes&fexp=24007246&beids=24350018&c=ANDROID&txp=4537434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIhAOBrAPa-X6d_nVKeVXl8ddkfVG-uUAK6NSlVr3HpMwXbAiB8yr5NQfM90ZcU_oxBeIiMFVOJmx3NCmv09WvaycJQ5Q%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIgRCGi20K-ZvdukYkBZOidcHpGPUpIBOkw-jZGEncsKQECIQC5h-rCfQhDTQFqocOTtQXcNZVA54oIqjweF0mN5GpzFA%3D%3D", + "width": 426, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "av01.0.00M.08", + "acodec": "none", + "dynamic_range": "SDR", + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "426x240", + "aspect_ratio": 1.77, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 114.108, + "format": "395 - 426x240 (240p)" + }, + { + "format_id": "229", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805268/ei/tFgEZcu0DoOD-gaqg47wBA/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/229/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/sgovp/clen%3D3019976%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D133%3Blmt%3D1694045014258984/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,29/mn/sn-5hne6nzy,sn-5hnekn7k/ms/au,rdu/mv/m/mvi/3/pl/22/initcwndbps/1957500/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1694783146/fvip/2/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,sgovp,vprv,playlist_type/sig/AOq0QJ8wRgIhAK2yZB-LpVu7_6FFB80HWx7hWoWkVw0S_w-QqUbIE8OJAiEA-3uSVPXggsuMHJp4vECyXEwukQoswRROk30WkuEpdu0%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRgIhAOxiPl9Yxyvs_0LF5tCaV-Z6e5_tamHAcKQxBh7St8tkAiEAulmwoGSX4EBiFmSTwJ99n49wRWxcpQh1hZK1xIdUVPc%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805268/ei/tFgEZcu0DoOD-gaqg47wBA/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C29/mn/sn-5hne6nzy%2Csn-5hnekn7k/ms/au%2Crdu/mv/m/mvi/3/pl/22/tx/24388770/txs/24388767%2C24388768%2C24388769%2C24388770%2C24388771%2C24388772%2C24388773/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1957500/vprv/1/go/1/mt/1694783146/fvip/2/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRQIhAL8hNlWRffc33Ibzb-OCH34lon8WNpNiKHeUFUFMzvlDAiAr5e33CzsKEX8k0MiF68H_7xmDW2b6HQSW0uiiFr2Rxg%3D%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRAIgF4H6KcG8d2n_e4oe9m5iMfJFj7-zvjFtzJfWCByyfVwCIDLMnTkaV3Szw249SnaNqBAw5vMO_DCwPJZkqzdT0P5p/file/index.m3u8", + "tbr": 225.675, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 5, + "has_drm": false, + "width": 426, + "height": 240, + "vcodec": "avc1.4D4015", + "acodec": "none", + "dynamic_range": "SDR", + "source_preference": -1, + "resolution": "426x240", + "aspect_ratio": 1.77, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 225.675, + "format": "229 - 426x240" + }, + { + "asr": null, + "filesize": 3019976, + "format_id": "133", + "format_note": "240p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 240, + "quality": 5.0, + "has_drm": false, + "tbr": 113.939, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805268&ei=tFgEZcu0DoOD-gaqg47wBA&ip=45.93.75.130&id=o-ALADwM6dkuCPsPIQiQ_ygvtMcP-xvew7ntgwcwtzWc4N&itag=133&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hne6nzy%2Csn-5hnekn7k&ms=au%2Crdu&mv=m&mvi=3&pl=22&initcwndbps=1957500&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=3019976&dur=212.040&lmt=1694045014258984&mt=1694783146&fvip=2&keepalive=yes&fexp=24007246&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIhAP7-DRzbJykHUKJeRsn6HtUaL0eUzqWESp-ympAfU5nTAiB1ez2yiKGOyhd-RqshrcyB18qQe2WSrCdUhy5v9pqP3A%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgHX4-RXGLMMOGBkRk1sGy7XnQ3wkahwF60RoxGmOabF0CIBpQjZOMeQQeqZX8JccDZAypFCP3chfxrtgzsfWCJJ0l", + "width": 426, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "avc1.4D4015", + "acodec": "none", + "dynamic_range": "SDR", + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "426x240", + "aspect_ratio": 1.77, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 113.939, + "format": "133 - 426x240 (240p)" + }, + { + "format_id": "604", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805268/ei/tFgEZcu0DoOD-gaqg47wBA/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/604/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D4021173%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D242%3Blmt%3D1694043379783601/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,29/mn/sn-5hne6nzy,sn-5hnekn7k/ms/au,rdu/mv/m/mvi/3/pl/22/initcwndbps/1957500/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1694783146/fvip/2/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRgIhAJXExrNJa22VZvMfaTzD429YPHRcemBT9-3QvF08XM1hAiEAn7meUognCFsmZthQEPIOD6k3Bvc9ZABis61S21hDBRQ%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIgbhhyD5yqyyIYiONN9J1mtDoXKD0w6rOgyDu6M06jnDECIQC-7cqHY9oqU0pwUlDmdHNwa_WKtxsZcchI7r3TmHR3dQ%3D%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805268/ei/tFgEZcu0DoOD-gaqg47wBA/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C29/mn/sn-5hne6nzy%2Csn-5hnekn7k/ms/au%2Crdu/mv/m/mvi/3/pl/22/tx/24388770/txs/24388767%2C24388768%2C24388769%2C24388770%2C24388771%2C24388772%2C24388773/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1957500/vprv/1/go/1/mt/1694783146/fvip/2/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRQIhAL8hNlWRffc33Ibzb-OCH34lon8WNpNiKHeUFUFMzvlDAiAr5e33CzsKEX8k0MiF68H_7xmDW2b6HQSW0uiiFr2Rxg%3D%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRAIgF4H6KcG8d2n_e4oe9m5iMfJFj7-zvjFtzJfWCByyfVwCIDLMnTkaV3Szw249SnaNqBAw5vMO_DCwPJZkqzdT0P5p/file/index.m3u8", + "tbr": 287.523, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 5, + "has_drm": false, + "width": 426, + "height": 240, + "vcodec": "vp09.00.20.08", + "acodec": "none", + "dynamic_range": "SDR", + "source_preference": -1, + "resolution": "426x240", + "aspect_ratio": 1.77, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 287.523, + "format": "604 - 426x240" + }, + { + "asr": null, + "filesize": 4021173, + "format_id": "242", + "format_note": "240p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 240, + "quality": 5.0, + "has_drm": false, + "tbr": 151.713, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805268&ei=tFgEZcu0DoOD-gaqg47wBA&ip=45.93.75.130&id=o-ALADwM6dkuCPsPIQiQ_ygvtMcP-xvew7ntgwcwtzWc4N&itag=242&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hne6nzy%2Csn-5hnekn7k&ms=au%2Crdu&mv=m&mvi=3&pl=22&initcwndbps=1957500&vprv=1&svpuc=1&mime=video%2Fwebm&gir=yes&clen=4021173&dur=212.040&lmt=1694043379783601&mt=1694783146&fvip=2&keepalive=yes&fexp=24007246&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhANcW3B_2RE3Jt_ysw-TH_vr_cJcDq8IlGwzMfl8KJbuLAiEAxQv9P2cZvIUvOaq74GZfBgG6iJv39AfrfINDoXbVtwQ%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgHX4-RXGLMMOGBkRk1sGy7XnQ3wkahwF60RoxGmOabF0CIBpQjZOMeQQeqZX8JccDZAypFCP3chfxrtgzsfWCJJ0l", + "width": 426, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "vp09.00.20.08", + "acodec": "none", + "dynamic_range": "SDR", + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "426x240", + "aspect_ratio": 1.77, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "webm", + "audio_ext": "none", + "abr": 0, + "vbr": 151.713, + "format": "242 - 426x240 (240p)" + }, + { + "asr": null, + "filesize": 5438397, + "format_id": "396", + "format_note": "360p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 360, + "quality": 6.0, + "has_drm": false, + "tbr": 205.183, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805268&ei=tFgEZaHmFN2Px_AP2tSt2AQ&ip=45.93.75.130&id=o-AEj4DudORoGviGzjggo2mjXrQpjRh8L2BrOU-wekY859&itag=396&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hne6nzy%2Csn-5hnekn7k&ms=au%2Crdu&mv=m&mvi=3&pl=22&initcwndbps=1957500&spc=UWF9f7_CV3gS4VV2VFq7hgxtUAyOlog&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=5438397&dur=212.040&lmt=1694042190822892&mt=1694783146&fvip=2&keepalive=yes&fexp=24007246&beids=24350018&c=ANDROID&txp=4537434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIgLzCC-yPo2ZSTmKsJ2e4jRyjlCZwMjAv8ZZkFusa1TTACIQCAqe_xOIth5xTCP_pgta9y03V39gvaGzHtL3YVa5D_aA%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIgRCGi20K-ZvdukYkBZOidcHpGPUpIBOkw-jZGEncsKQECIQC5h-rCfQhDTQFqocOTtQXcNZVA54oIqjweF0mN5GpzFA%3D%3D", + "width": 640, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "av01.0.01M.08", + "acodec": "none", + "dynamic_range": "SDR", + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "640x360", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 205.183, + "format": "396 - 640x360 (360p)" + }, + { + "format_id": "230", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805268/ei/tFgEZcu0DoOD-gaqg47wBA/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/230/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/sgovp/clen%3D5678772%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D134%3Blmt%3D1694045013473544/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,29/mn/sn-5hne6nzy,sn-5hnekn7k/ms/au,rdu/mv/m/mvi/3/pl/22/initcwndbps/1957500/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1694783146/fvip/2/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,sgovp,vprv,playlist_type/sig/AOq0QJ8wRQIhAPsd8-fEmDJQlMCGNL9vmN4MUF_zlN5PLfr0DYfVoezzAiBU7T2m437twygSiryf0b-u-OIsQTeldPovQ-zSp0ETkw%3D%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIhALsd4jV3YmEG-liDMzghp0L-sTyGWpDf1iMm_UzTHXs9AiAIAfs4pb5FPTUO0hJxAocJtAUunw2KTmMXci4VOGTaGA%3D%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805268/ei/tFgEZcu0DoOD-gaqg47wBA/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C29/mn/sn-5hne6nzy%2Csn-5hnekn7k/ms/au%2Crdu/mv/m/mvi/3/pl/22/tx/24388770/txs/24388767%2C24388768%2C24388769%2C24388770%2C24388771%2C24388772%2C24388773/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1957500/vprv/1/go/1/mt/1694783146/fvip/2/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRQIhAL8hNlWRffc33Ibzb-OCH34lon8WNpNiKHeUFUFMzvlDAiAr5e33CzsKEX8k0MiF68H_7xmDW2b6HQSW0uiiFr2Rxg%3D%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRAIgF4H6KcG8d2n_e4oe9m5iMfJFj7-zvjFtzJfWCByyfVwCIDLMnTkaV3Szw249SnaNqBAw5vMO_DCwPJZkqzdT0P5p/file/index.m3u8", + "tbr": 478.155, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 6, + "has_drm": false, + "width": 640, + "height": 360, + "vcodec": "avc1.4D401E", + "acodec": "none", + "dynamic_range": "SDR", + "source_preference": -1, + "resolution": "640x360", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 478.155, + "format": "230 - 640x360" + }, + { + "asr": null, + "filesize": 5678772, + "format_id": "134", + "format_note": "360p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 360, + "quality": 6.0, + "has_drm": false, + "tbr": 214.252, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805268&ei=tFgEZcu0DoOD-gaqg47wBA&ip=45.93.75.130&id=o-ALADwM6dkuCPsPIQiQ_ygvtMcP-xvew7ntgwcwtzWc4N&itag=134&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hne6nzy%2Csn-5hnekn7k&ms=au%2Crdu&mv=m&mvi=3&pl=22&initcwndbps=1957500&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=5678772&dur=212.040&lmt=1694045013473544&mt=1694783146&fvip=2&keepalive=yes&fexp=24007246&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIgI9UX8Vzs8WLiUAfYg7PAfKfqjj8B9Wja5aUfFUGO59gCIQCGtnmhUpjWdxW1suD-k_EmGlMnwCZ3xXTSD8MQSLsXFg%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgHX4-RXGLMMOGBkRk1sGy7XnQ3wkahwF60RoxGmOabF0CIBpQjZOMeQQeqZX8JccDZAypFCP3chfxrtgzsfWCJJ0l", + "width": 640, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "avc1.4D401E", + "acodec": "none", + "dynamic_range": "SDR", + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "640x360", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 214.252, + "format": "134 - 640x360 (360p)" + }, + { + "asr": 44100, + "filesize": null, + "format_id": "18", + "format_note": "360p", + "source_preference": -1, + "fps": 25, + "audio_channels": 2, + "height": 360, + "quality": 6.0, + "has_drm": false, + "tbr": 343.32, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805268&ei=tFgEZaHmFN2Px_AP2tSt2AQ&ip=45.93.75.130&id=o-AEj4DudORoGviGzjggo2mjXrQpjRh8L2BrOU-wekY859&itag=18&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hne6nzy%2Csn-5hnekn7k&ms=au%2Crdu&mv=m&mvi=3&pl=22&initcwndbps=1957500&spc=UWF9f7_CV3gS4VV2VFq7hgxtUAyOlog&vprv=1&svpuc=1&mime=video%2Fmp4&cnr=14&ratebypass=yes&dur=212.091&lmt=1694045104514388&mt=1694783146&fvip=2&fexp=24007246&beids=24350018&c=ANDROID&txp=4538434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Ccnr%2Cratebypass%2Cdur%2Clmt&sig=AOq0QJ8wRAIgQ9O6CEkJyhZCM4l7TXmLw-1IyhS5pynH9hVHJ1yzDeQCIAZmevy2E9fWT3oyNdbJyqHI-H7nfWWWfWaaMm-oRnX4&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIgRCGi20K-ZvdukYkBZOidcHpGPUpIBOkw-jZGEncsKQECIQC5h-rCfQhDTQFqocOTtQXcNZVA54oIqjweF0mN5GpzFA%3D%3D", + "width": 640, + "language": "en", + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "avc1.42001E", + "acodec": "mp4a.40.2", + "dynamic_range": "SDR", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "640x360", + "aspect_ratio": 1.78, + "filesize_approx": 9316331, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "vbr": null, + "abr": null, + "format": "18 - 640x360 (360p)" + }, + { + "format_id": "605", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805268/ei/tFgEZcu0DoOD-gaqg47wBA/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/605/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D6902164%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D243%3Blmt%3D1694043349554753/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,29/mn/sn-5hne6nzy,sn-5hnekn7k/ms/au,rdu/mv/m/mvi/3/pl/22/initcwndbps/1957500/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1694783146/fvip/2/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRQIhAJxexWH26Vo4tGjaU7SVipOCPxzVsmcFOarU_bRj0ay-AiB6QmeXiAc_08341YesRBEC6KgIU3HT-cwPdhnCgAtOVw%3D%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRgIhAIVHx3z1WRFDckwGBmogzli7IcwwILf__AQQpA4kOSckAiEA550IxTq_8KIYplwlBr7OLXHegKhoOl5rsgl-T8znV24%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805268/ei/tFgEZcu0DoOD-gaqg47wBA/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C29/mn/sn-5hne6nzy%2Csn-5hnekn7k/ms/au%2Crdu/mv/m/mvi/3/pl/22/tx/24388770/txs/24388767%2C24388768%2C24388769%2C24388770%2C24388771%2C24388772%2C24388773/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1957500/vprv/1/go/1/mt/1694783146/fvip/2/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRQIhAL8hNlWRffc33Ibzb-OCH34lon8WNpNiKHeUFUFMzvlDAiAr5e33CzsKEX8k0MiF68H_7xmDW2b6HQSW0uiiFr2Rxg%3D%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRAIgF4H6KcG8d2n_e4oe9m5iMfJFj7-zvjFtzJfWCByyfVwCIDLMnTkaV3Szw249SnaNqBAw5vMO_DCwPJZkqzdT0P5p/file/index.m3u8", + "tbr": 566.25, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 6, + "has_drm": false, + "width": 640, + "height": 360, + "vcodec": "vp09.00.21.08", + "acodec": "none", + "dynamic_range": "SDR", + "source_preference": -1, + "resolution": "640x360", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 566.25, + "format": "605 - 640x360" + }, + { + "asr": null, + "filesize": 6902164, + "format_id": "243", + "format_note": "360p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 360, + "quality": 6.0, + "has_drm": false, + "tbr": 260.409, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805268&ei=tFgEZcu0DoOD-gaqg47wBA&ip=45.93.75.130&id=o-ALADwM6dkuCPsPIQiQ_ygvtMcP-xvew7ntgwcwtzWc4N&itag=243&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hne6nzy%2Csn-5hnekn7k&ms=au%2Crdu&mv=m&mvi=3&pl=22&initcwndbps=1957500&vprv=1&svpuc=1&mime=video%2Fwebm&gir=yes&clen=6902164&dur=212.040&lmt=1694043349554753&mt=1694783146&fvip=2&keepalive=yes&fexp=24007246&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRAIgFlaKhvga5JkqykyXnX2xE8XiiqYW_VvEBrecRfwfwtwCIDJCxTDJPzsPOAHdblWxH8cTlefGYqGq3n6gWISz4Mg6&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgHX4-RXGLMMOGBkRk1sGy7XnQ3wkahwF60RoxGmOabF0CIBpQjZOMeQQeqZX8JccDZAypFCP3chfxrtgzsfWCJJ0l", + "width": 640, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "vp09.00.21.08", + "acodec": "none", + "dynamic_range": "SDR", + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "640x360", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "webm", + "audio_ext": "none", + "abr": 0, + "vbr": 260.409, + "format": "243 - 640x360 (360p)" + }, + { + "asr": null, + "filesize": 9434981, + "format_id": "397", + "format_note": "480p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 480, + "quality": 7.0, + "has_drm": false, + "tbr": 355.969, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805268&ei=tFgEZaHmFN2Px_AP2tSt2AQ&ip=45.93.75.130&id=o-AEj4DudORoGviGzjggo2mjXrQpjRh8L2BrOU-wekY859&itag=397&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hne6nzy%2Csn-5hnekn7k&ms=au%2Crdu&mv=m&mvi=3&pl=22&initcwndbps=1957500&spc=UWF9f7_CV3gS4VV2VFq7hgxtUAyOlog&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=9434981&dur=212.040&lmt=1694042458043976&mt=1694783146&fvip=2&keepalive=yes&fexp=24007246&beids=24350018&c=ANDROID&txp=4537434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRAIgGIoLoe44Xe1thEQ_qjsc2WHA1GPW-htWsPL_QfPjgSQCIHq1BE6WV_vRQ1OkcSmYRHHpfP3c5daevv5WwHUD3wqW&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIgRCGi20K-ZvdukYkBZOidcHpGPUpIBOkw-jZGEncsKQECIQC5h-rCfQhDTQFqocOTtQXcNZVA54oIqjweF0mN5GpzFA%3D%3D", + "width": 854, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "av01.0.04M.08", + "acodec": "none", + "dynamic_range": "SDR", + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "854x480", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 355.969, + "format": "397 - 854x480 (480p)" + }, + { + "format_id": "231", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805268/ei/tFgEZcu0DoOD-gaqg47wBA/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/231/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/sgovp/clen%3D8683274%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D135%3Blmt%3D1694045045723793/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,29/mn/sn-5hne6nzy,sn-5hnekn7k/ms/au,rdu/mv/m/mvi/3/pl/22/initcwndbps/1957500/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1694783146/fvip/2/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,sgovp,vprv,playlist_type/sig/AOq0QJ8wRgIhALPVRc2f2Obkuz1sdr6BV9MyEnkUBzkmMGM5Hqci5JuJAiEA8F1Yul9YL-Zry_0wpNgq3U1y2ZNVbIPicWR73h_JuKo%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRAIgMSeNvXWq1qWsFqCjU9l8OfbjtWzVvVvyM_AIfmvT-nYCIEvrRlrArUOUeo9HWQ7Q75Va9V2bQfJosWVypJauqciw/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805268/ei/tFgEZcu0DoOD-gaqg47wBA/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C29/mn/sn-5hne6nzy%2Csn-5hnekn7k/ms/au%2Crdu/mv/m/mvi/3/pl/22/tx/24388770/txs/24388767%2C24388768%2C24388769%2C24388770%2C24388771%2C24388772%2C24388773/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1957500/vprv/1/go/1/mt/1694783146/fvip/2/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRQIhAL8hNlWRffc33Ibzb-OCH34lon8WNpNiKHeUFUFMzvlDAiAr5e33CzsKEX8k0MiF68H_7xmDW2b6HQSW0uiiFr2Rxg%3D%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRAIgF4H6KcG8d2n_e4oe9m5iMfJFj7-zvjFtzJfWCByyfVwCIDLMnTkaV3Szw249SnaNqBAw5vMO_DCwPJZkqzdT0P5p/file/index.m3u8", + "tbr": 660.067, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 7, + "has_drm": false, + "width": 854, + "height": 480, + "vcodec": "avc1.4D401E", + "acodec": "none", + "dynamic_range": "SDR", + "source_preference": -1, + "resolution": "854x480", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 660.067, + "format": "231 - 854x480" + }, + { + "asr": null, + "filesize": 8683274, + "format_id": "135", + "format_note": "480p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 480, + "quality": 7.0, + "has_drm": false, + "tbr": 327.608, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805268&ei=tFgEZcu0DoOD-gaqg47wBA&ip=45.93.75.130&id=o-ALADwM6dkuCPsPIQiQ_ygvtMcP-xvew7ntgwcwtzWc4N&itag=135&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hne6nzy%2Csn-5hnekn7k&ms=au%2Crdu&mv=m&mvi=3&pl=22&initcwndbps=1957500&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=8683274&dur=212.040&lmt=1694045045723793&mt=1694783146&fvip=2&keepalive=yes&fexp=24007246&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhAN7VpnHyU08_5bd3nLSN0T_I0G0XKAGpstkSN8hJmG7SAiEAoywzkEOX7Xvl3gJxH22DIWZlTFoUACTgUR3KCdWnNII%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgHX4-RXGLMMOGBkRk1sGy7XnQ3wkahwF60RoxGmOabF0CIBpQjZOMeQQeqZX8JccDZAypFCP3chfxrtgzsfWCJJ0l", + "width": 854, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "avc1.4D401E", + "acodec": "none", + "dynamic_range": "SDR", + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "854x480", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 327.608, + "format": "135 - 854x480 (480p)" + }, + { + "format_id": "606", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805268/ei/tFgEZcu0DoOD-gaqg47wBA/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/606/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D10927666%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D244%3Blmt%3D1694043369037289/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,29/mn/sn-5hne6nzy,sn-5hnekn7k/ms/au,rdu/mv/m/mvi/3/pl/22/initcwndbps/1957500/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1694783146/fvip/2/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRQIgKYQax37qT30x1n2xlCZ-MNpGPVjPCOLXz86zUuioIg0CIQDulgGfFF1mDON6pqV0wlYYEvqe5XvcPBxS-Fxu994RqQ%3D%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRgIhAIa8QTnhFp9MOZ1suF489yzC2ZkBGVPLp2bEzz9dR8WIAiEAnoLnAxgbtt5aNYJUgBTY9ms7VVWkHHKCiH638UCuKtk%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805268/ei/tFgEZcu0DoOD-gaqg47wBA/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C29/mn/sn-5hne6nzy%2Csn-5hnekn7k/ms/au%2Crdu/mv/m/mvi/3/pl/22/tx/24388770/txs/24388767%2C24388768%2C24388769%2C24388770%2C24388771%2C24388772%2C24388773/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1957500/vprv/1/go/1/mt/1694783146/fvip/2/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRQIhAL8hNlWRffc33Ibzb-OCH34lon8WNpNiKHeUFUFMzvlDAiAr5e33CzsKEX8k0MiF68H_7xmDW2b6HQSW0uiiFr2Rxg%3D%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRAIgF4H6KcG8d2n_e4oe9m5iMfJFj7-zvjFtzJfWCByyfVwCIDLMnTkaV3Szw249SnaNqBAw5vMO_DCwPJZkqzdT0P5p/file/index.m3u8", + "tbr": 733.359, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 7, + "has_drm": false, + "width": 854, + "height": 480, + "vcodec": "vp09.00.30.08", + "acodec": "none", + "dynamic_range": "SDR", + "source_preference": -1, + "resolution": "854x480", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 733.359, + "format": "606 - 854x480" + }, + { + "asr": null, + "filesize": 10927666, + "format_id": "244", + "format_note": "480p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 480, + "quality": 7.0, + "has_drm": false, + "tbr": 412.286, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805268&ei=tFgEZcu0DoOD-gaqg47wBA&ip=45.93.75.130&id=o-ALADwM6dkuCPsPIQiQ_ygvtMcP-xvew7ntgwcwtzWc4N&itag=244&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hne6nzy%2Csn-5hnekn7k&ms=au%2Crdu&mv=m&mvi=3&pl=22&initcwndbps=1957500&vprv=1&svpuc=1&mime=video%2Fwebm&gir=yes&clen=10927666&dur=212.040&lmt=1694043369037289&mt=1694783146&fvip=2&keepalive=yes&fexp=24007246&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRAIfXrfMaA8LBSnaF3occA67EUNFJ1_7SIva5fm0zJlQfAIhAIipqpihUgBYI2wfaLJFfgptt_mW9nzrCV9gmy4NwfOs&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgHX4-RXGLMMOGBkRk1sGy7XnQ3wkahwF60RoxGmOabF0CIBpQjZOMeQQeqZX8JccDZAypFCP3chfxrtgzsfWCJJ0l", + "width": 854, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "vp09.00.30.08", + "acodec": "none", + "dynamic_range": "SDR", + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "854x480", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "webm", + "audio_ext": "none", + "abr": 0, + "vbr": 412.286, + "format": "244 - 854x480 (480p)" + }, + { + "asr": 44100, + "filesize": null, + "format_id": "22", + "format_note": "720p", + "source_preference": -5, + "fps": 25, + "audio_channels": 2, + "height": 720, + "quality": 8.0, + "has_drm": false, + "tbr": 762.182, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805268&ei=tFgEZaHmFN2Px_AP2tSt2AQ&ip=45.93.75.130&id=o-AEj4DudORoGviGzjggo2mjXrQpjRh8L2BrOU-wekY859&itag=22&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hne6nzy%2Csn-5hnekn7k&ms=au%2Crdu&mv=m&mvi=3&pl=22&initcwndbps=1957500&spc=UWF9f7_CV3gS4VV2VFq7hgxtUAyOlog&vprv=1&svpuc=1&mime=video%2Fmp4&cnr=14&ratebypass=yes&dur=212.091&lmt=1694045086815467&mt=1694783146&fvip=2&fexp=24007246&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Ccnr%2Cratebypass%2Cdur%2Clmt&sig=AOq0QJ8wRQIhAO2IJciEtkI3PvYyVC_zkyo61I70wYJQXuGOMueeacrKAiA-UAdaJSlqqkfaa6QtqVnC_BJJZn7BXs85gh_fdbGoSg%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIgRCGi20K-ZvdukYkBZOidcHpGPUpIBOkw-jZGEncsKQECIQC5h-rCfQhDTQFqocOTtQXcNZVA54oIqjweF0mN5GpzFA%3D%3D", + "width": 1280, + "language": "en", + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "avc1.64001F", + "acodec": "mp4a.40.2", + "dynamic_range": "SDR", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "1280x720", + "aspect_ratio": 1.78, + "filesize_approx": 20682570, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "vbr": null, + "abr": null, + "format": "22 - 1280x720 (720p)" + }, + { + "asr": null, + "filesize": 17466721, + "format_id": "398", + "format_note": "720p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 720, + "quality": 8.0, + "has_drm": false, + "tbr": 658.997, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805268&ei=tFgEZaHmFN2Px_AP2tSt2AQ&ip=45.93.75.130&id=o-AEj4DudORoGviGzjggo2mjXrQpjRh8L2BrOU-wekY859&itag=398&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hne6nzy%2Csn-5hnekn7k&ms=au%2Crdu&mv=m&mvi=3&pl=22&initcwndbps=1957500&spc=UWF9f7_CV3gS4VV2VFq7hgxtUAyOlog&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=17466721&dur=212.040&lmt=1694042319819525&mt=1694783146&fvip=2&keepalive=yes&fexp=24007246&beids=24350018&c=ANDROID&txp=4537434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhAPywBnw1_I4KYIHKK24AJyYZfWhx7SQJVRZ8uBGqXJhKAiEAxq_PQFEOlJYSrgyXUUjebDhJqF95a2RjUpPY2qES9PY%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIgRCGi20K-ZvdukYkBZOidcHpGPUpIBOkw-jZGEncsKQECIQC5h-rCfQhDTQFqocOTtQXcNZVA54oIqjweF0mN5GpzFA%3D%3D", + "width": 1280, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "av01.0.05M.08", + "acodec": "none", + "dynamic_range": "SDR", + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "1280x720", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 658.997, + "format": "398 - 1280x720 (720p)" + }, + { + "format_id": "232", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805268/ei/tFgEZcu0DoOD-gaqg47wBA/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/232/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/sgovp/clen%3D16780212%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D136%3Blmt%3D1694045071129751/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,29/mn/sn-5hne6nzy,sn-5hnekn7k/ms/au,rdu/mv/m/mvi/3/pl/22/initcwndbps/1957500/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1694783146/fvip/2/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,sgovp,vprv,playlist_type/sig/AOq0QJ8wRQIgbhrGclyS72fpLtzdXVU9RDmFGHjBDZouGsDQpvbUQC4CIQDpfA4mDk5nk89ajQcD_glSxmcquRJP60hXgPxY_I_ZBw%3D%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRgIhAMuqkbeFuDFY6HmhH7v3KH-qIYGXKazEx-mLveUdmpdpAiEAs2EroRUbYwlaalalseSytVEqn6JsUcZiitcLMEMfGbM%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805268/ei/tFgEZcu0DoOD-gaqg47wBA/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C29/mn/sn-5hne6nzy%2Csn-5hnekn7k/ms/au%2Crdu/mv/m/mvi/3/pl/22/tx/24388770/txs/24388767%2C24388768%2C24388769%2C24388770%2C24388771%2C24388772%2C24388773/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1957500/vprv/1/go/1/mt/1694783146/fvip/2/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRQIhAL8hNlWRffc33Ibzb-OCH34lon8WNpNiKHeUFUFMzvlDAiAr5e33CzsKEX8k0MiF68H_7xmDW2b6HQSW0uiiFr2Rxg%3D%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRAIgF4H6KcG8d2n_e4oe9m5iMfJFj7-zvjFtzJfWCByyfVwCIDLMnTkaV3Szw249SnaNqBAw5vMO_DCwPJZkqzdT0P5p/file/index.m3u8", + "tbr": 1130.986, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 8, + "has_drm": false, + "width": 1280, + "height": 720, + "vcodec": "avc1.4D401F", + "acodec": "none", + "dynamic_range": "SDR", + "source_preference": -1, + "resolution": "1280x720", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 1130.986, + "format": "232 - 1280x720" + }, + { + "asr": null, + "filesize": 16780212, + "format_id": "136", + "format_note": "720p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 720, + "quality": 8.0, + "has_drm": false, + "tbr": 633.096, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805268&ei=tFgEZcu0DoOD-gaqg47wBA&ip=45.93.75.130&id=o-ALADwM6dkuCPsPIQiQ_ygvtMcP-xvew7ntgwcwtzWc4N&itag=136&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hne6nzy%2Csn-5hnekn7k&ms=au%2Crdu&mv=m&mvi=3&pl=22&initcwndbps=1957500&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=16780212&dur=212.040&lmt=1694045071129751&mt=1694783146&fvip=2&keepalive=yes&fexp=24007246&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRAIgSRRddsHyGpCxzgpSqQxmStMIq_Gm7czCOT98gtLFwwkCIGVj5J0frFAsFbd4YVMZWQlnTH1K32SUuJ8OhY8Ka7FT&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgHX4-RXGLMMOGBkRk1sGy7XnQ3wkahwF60RoxGmOabF0CIBpQjZOMeQQeqZX8JccDZAypFCP3chfxrtgzsfWCJJ0l", + "width": 1280, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "avc1.4D401F", + "acodec": "none", + "dynamic_range": "SDR", + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "1280x720", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 633.096, + "format": "136 - 1280x720 (720p)" + }, + { + "format_id": "609", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805268/ei/tFgEZcu0DoOD-gaqg47wBA/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/609/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D15359727%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D247%3Blmt%3D1694043486219683/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,29/mn/sn-5hne6nzy,sn-5hnekn7k/ms/au,rdu/mv/m/mvi/3/pl/22/initcwndbps/1957500/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1694783146/fvip/2/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRgIhAOL1Az7tRckedkj_PlDPG_MLb7ZLWQR3lkfCPgIJXd2ZAiEArFERGBCxvgl1prUWkikX0zu8y25kKGIddtOLea5sxVA%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIhALJWjDpuy9OEznLN5GSPvuFnF9fybPAeESDF9b8fwGUPAiBSBQF0jpakjC9BBw-hLVF2AunwNfzMUaxajirGWWDCpA%3D%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805268/ei/tFgEZcu0DoOD-gaqg47wBA/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C29/mn/sn-5hne6nzy%2Csn-5hnekn7k/ms/au%2Crdu/mv/m/mvi/3/pl/22/tx/24388770/txs/24388767%2C24388768%2C24388769%2C24388770%2C24388771%2C24388772%2C24388773/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1957500/vprv/1/go/1/mt/1694783146/fvip/2/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRQIhAL8hNlWRffc33Ibzb-OCH34lon8WNpNiKHeUFUFMzvlDAiAr5e33CzsKEX8k0MiF68H_7xmDW2b6HQSW0uiiFr2Rxg%3D%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRAIgF4H6KcG8d2n_e4oe9m5iMfJFj7-zvjFtzJfWCByyfVwCIDLMnTkaV3Szw249SnaNqBAw5vMO_DCwPJZkqzdT0P5p/file/index.m3u8", + "tbr": 1179.472, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 8, + "has_drm": false, + "width": 1280, + "height": 720, + "vcodec": "vp09.00.31.08", + "acodec": "none", + "dynamic_range": "SDR", + "source_preference": -1, + "resolution": "1280x720", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 1179.472, + "format": "609 - 1280x720" + }, + { + "asr": null, + "filesize": 15359727, + "format_id": "247", + "format_note": "720p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 720, + "quality": 8.0, + "has_drm": false, + "tbr": 579.502, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805268&ei=tFgEZcu0DoOD-gaqg47wBA&ip=45.93.75.130&id=o-ALADwM6dkuCPsPIQiQ_ygvtMcP-xvew7ntgwcwtzWc4N&itag=247&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hne6nzy%2Csn-5hnekn7k&ms=au%2Crdu&mv=m&mvi=3&pl=22&initcwndbps=1957500&vprv=1&svpuc=1&mime=video%2Fwebm&gir=yes&clen=15359727&dur=212.040&lmt=1694043486219683&mt=1694783146&fvip=2&keepalive=yes&fexp=24007246&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIgOZCiWUfgJJ7EhGWaF7VH8ClUVLTgxTj4BwkKSlBgYiYCIQCfzpz7XVToSR5C20eqqfJN2-Arc5LpaYp8QySapFOS5A%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgHX4-RXGLMMOGBkRk1sGy7XnQ3wkahwF60RoxGmOabF0CIBpQjZOMeQQeqZX8JccDZAypFCP3chfxrtgzsfWCJJ0l", + "width": 1280, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "vp09.00.31.08", + "acodec": "none", + "dynamic_range": "SDR", + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "1280x720", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "webm", + "audio_ext": "none", + "abr": 0, + "vbr": 579.502, + "format": "247 - 1280x720 (720p)" + }, + { + "asr": null, + "filesize": 31265835, + "format_id": "399", + "format_note": "1080p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 1080, + "quality": 9.0, + "has_drm": false, + "tbr": 1179.62, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805268&ei=tFgEZaHmFN2Px_AP2tSt2AQ&ip=45.93.75.130&id=o-AEj4DudORoGviGzjggo2mjXrQpjRh8L2BrOU-wekY859&itag=399&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hne6nzy%2Csn-5hnekn7k&ms=au%2Crdu&mv=m&mvi=3&pl=22&initcwndbps=1957500&spc=UWF9f7_CV3gS4VV2VFq7hgxtUAyOlog&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=31265835&dur=212.040&lmt=1694042163788395&mt=1694783146&fvip=2&keepalive=yes&fexp=24007246&beids=24350018&c=ANDROID&txp=4537434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIhAP3UyiR-zsKUxBoO4PBga2JD3Yd3hKqOqvH7ImC75ulSAiA3-7uZ0rcIPZI-ozv9d1IGSMaMN6_cLePQLeo78PFcsw%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIgRCGi20K-ZvdukYkBZOidcHpGPUpIBOkw-jZGEncsKQECIQC5h-rCfQhDTQFqocOTtQXcNZVA54oIqjweF0mN5GpzFA%3D%3D", + "width": 1920, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "av01.0.08M.08", + "acodec": "none", + "dynamic_range": "SDR", + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "1920x1080", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 1179.62, + "format": "399 - 1920x1080 (1080p)" + }, + { + "format_id": "270", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805268/ei/tFgEZcu0DoOD-gaqg47wBA/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/270/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/sgovp/clen%3D80166145%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D137%3Blmt%3D1694045208995966/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,29/mn/sn-5hne6nzy,sn-5hnekn7k/ms/au,rdu/mv/m/mvi/3/pl/22/initcwndbps/1957500/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1694783146/fvip/2/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,sgovp,vprv,playlist_type/sig/AOq0QJ8wRAIgD36sIi3KkXGfoFndEWd4zwW1I_CM_QTl8bgFexU9pOYCIEwpCkWFOrqNTbezacGSmfL5A7K1nsntn_bunWWYwpng/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIgM_-3XdDrdsnF4FioWwe9vRaX2iRWlRDklNoUaIGHuaQCIQDNgXEH4j6FtvPy3ccR1TWikUHWev1h2ysvaEiOtiNM4A%3D%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805268/ei/tFgEZcu0DoOD-gaqg47wBA/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C29/mn/sn-5hne6nzy%2Csn-5hnekn7k/ms/au%2Crdu/mv/m/mvi/3/pl/22/tx/24388770/txs/24388767%2C24388768%2C24388769%2C24388770%2C24388771%2C24388772%2C24388773/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1957500/vprv/1/go/1/mt/1694783146/fvip/2/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRQIhAL8hNlWRffc33Ibzb-OCH34lon8WNpNiKHeUFUFMzvlDAiAr5e33CzsKEX8k0MiF68H_7xmDW2b6HQSW0uiiFr2Rxg%3D%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRAIgF4H6KcG8d2n_e4oe9m5iMfJFj7-zvjFtzJfWCByyfVwCIDLMnTkaV3Szw249SnaNqBAw5vMO_DCwPJZkqzdT0P5p/file/index.m3u8", + "tbr": 4901.412, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 9, + "has_drm": false, + "width": 1920, + "height": 1080, + "vcodec": "avc1.640028", + "acodec": "none", + "dynamic_range": "SDR", + "source_preference": -1, + "resolution": "1920x1080", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 4901.412, + "format": "270 - 1920x1080" + }, + { + "asr": null, + "filesize": 80166145, + "format_id": "137", + "format_note": "1080p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 1080, + "quality": 9.0, + "has_drm": false, + "tbr": 3024.566, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805268&ei=tFgEZcu0DoOD-gaqg47wBA&ip=45.93.75.130&id=o-ALADwM6dkuCPsPIQiQ_ygvtMcP-xvew7ntgwcwtzWc4N&itag=137&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hne6nzy%2Csn-5hnekn7k&ms=au%2Crdu&mv=m&mvi=3&pl=22&initcwndbps=1957500&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=80166145&dur=212.040&lmt=1694045208995966&mt=1694783146&fvip=2&keepalive=yes&fexp=24007246&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRAIgPfkitkkFuxF18kiTpRsPUzhfBZsp5WX8WR16WT4FWN4CIFsiyOwbYDHzytWsvXweLw50nXGVRFDcIjy-lCSY9kmK&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgHX4-RXGLMMOGBkRk1sGy7XnQ3wkahwF60RoxGmOabF0CIBpQjZOMeQQeqZX8JccDZAypFCP3chfxrtgzsfWCJJ0l", + "width": 1920, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "avc1.640028", + "acodec": "none", + "dynamic_range": "SDR", + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "1920x1080", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 3024.566, + "format": "137 - 1920x1080 (1080p)" + }, + { + "format_id": "614", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805268/ei/tFgEZcu0DoOD-gaqg47wBA/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/614/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D40874930%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D248%3Blmt%3D1694044655610179/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,29/mn/sn-5hne6nzy,sn-5hnekn7k/ms/au,rdu/mv/m/mvi/3/pl/22/initcwndbps/1957500/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1694783146/fvip/2/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRQIgbn5GlPWDCdfs18jV5BDKc6IVmgq5xKuZIs53-6LbJbACIQDIBnFJvI5uQmRy7_LHF_bUjAX46uT0y2xMxJQxUaABaw%3D%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIhALtFWwbQc0bud7Owmpa-scrnBCDk6O14mBrEOGNfsGVBAiBYOeKoRWnfgZKPfO7zNJLD9T8Ed4dTa9BRZeDs51IT9g%3D%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805268/ei/tFgEZcu0DoOD-gaqg47wBA/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C29/mn/sn-5hne6nzy%2Csn-5hnekn7k/ms/au%2Crdu/mv/m/mvi/3/pl/22/tx/24388770/txs/24388767%2C24388768%2C24388769%2C24388770%2C24388771%2C24388772%2C24388773/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1957500/vprv/1/go/1/mt/1694783146/fvip/2/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRQIhAL8hNlWRffc33Ibzb-OCH34lon8WNpNiKHeUFUFMzvlDAiAr5e33CzsKEX8k0MiF68H_7xmDW2b6HQSW0uiiFr2Rxg%3D%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRAIgF4H6KcG8d2n_e4oe9m5iMfJFj7-zvjFtzJfWCByyfVwCIDLMnTkaV3Szw249SnaNqBAw5vMO_DCwPJZkqzdT0P5p/file/index.m3u8", + "tbr": 2831.123, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 9, + "has_drm": false, + "width": 1920, + "height": 1080, + "vcodec": "vp09.00.40.08", + "acodec": "none", + "dynamic_range": "SDR", + "source_preference": -1, + "resolution": "1920x1080", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 2831.123, + "format": "614 - 1920x1080" + }, + { + "asr": null, + "filesize": 40874930, + "format_id": "248", + "format_note": "1080p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 1080, + "quality": 9.0, + "has_drm": false, + "tbr": 1542.159, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805268&ei=tFgEZcu0DoOD-gaqg47wBA&ip=45.93.75.130&id=o-ALADwM6dkuCPsPIQiQ_ygvtMcP-xvew7ntgwcwtzWc4N&itag=248&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hne6nzy%2Csn-5hnekn7k&ms=au%2Crdu&mv=m&mvi=3&pl=22&initcwndbps=1957500&vprv=1&svpuc=1&mime=video%2Fwebm&gir=yes&clen=40874930&dur=212.040&lmt=1694044655610179&mt=1694783146&fvip=2&keepalive=yes&fexp=24007246&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIgT7VwysCFd3nXvaSSiJoVxkNj5jfMPSeitLsQmy_S1b4CIQDWFiZSIH3tV4hQRtHa9DbzdYL8RQpbKD_6aeNZ7t-3IA%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgHX4-RXGLMMOGBkRk1sGy7XnQ3wkahwF60RoxGmOabF0CIBpQjZOMeQQeqZX8JccDZAypFCP3chfxrtgzsfWCJJ0l", + "width": 1920, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "vp09.00.40.08", + "acodec": "none", + "dynamic_range": "SDR", + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "1920x1080", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "webm", + "audio_ext": "none", + "abr": 0, + "vbr": 1542.159, + "format": "248 - 1920x1080 (1080p)" + }, + { + "format_id": "616", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805268/ei/tFgEZcu0DoOD-gaqg47wBA/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/616/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D99471214%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D356%3Blmt%3D1694043438471036/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,29/mn/sn-5hne6nzy,sn-5hnekn7k/ms/au,rdu/mv/m/mvi/3/pl/22/initcwndbps/1957500/vprv/1/playlist_type/DVR/dover/13/txp/4532434/mt/1694783146/fvip/2/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRQIhALAASH0_ZDQQoMA82qWNCXSHPZ0bb9TQldIs7AAxktiiAiASA5bQy7IAa6NwdGIOpfye5OgcY_BNuo0WgSdh84tosw%3D%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRgIhAIsDcLGH8KJpQpBgyJ5VWlDxfr75HyO8hMSVS9v7nRu4AiEA2xjtLZOzeNFoJlxwCsH3YqsUQt-BF_4gikhi_P4FbBc%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805268/ei/tFgEZcu0DoOD-gaqg47wBA/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C29/mn/sn-5hne6nzy%2Csn-5hnekn7k/ms/au%2Crdu/mv/m/mvi/3/pl/22/tx/24388770/txs/24388767%2C24388768%2C24388769%2C24388770%2C24388771%2C24388772%2C24388773/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1957500/vprv/1/go/1/mt/1694783146/fvip/2/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRQIhAL8hNlWRffc33Ibzb-OCH34lon8WNpNiKHeUFUFMzvlDAiAr5e33CzsKEX8k0MiF68H_7xmDW2b6HQSW0uiiFr2Rxg%3D%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRAIgF4H6KcG8d2n_e4oe9m5iMfJFj7-zvjFtzJfWCByyfVwCIDLMnTkaV3Szw249SnaNqBAw5vMO_DCwPJZkqzdT0P5p/file/index.m3u8", + "tbr": 5704.254, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 9, + "has_drm": false, + "width": 1920, + "height": 1080, + "vcodec": "vp09.00.40.08", + "acodec": "none", + "dynamic_range": "SDR", + "source_preference": 99, + "format_note": "Premium", + "resolution": "1920x1080", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 5704.254, + "format": "616 - 1920x1080 (Premium)" + } + ], + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/3.jpg", + "preference": -37, + "id": "0" + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/3.webp", + "preference": -36, + "id": "1" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/2.jpg", + "preference": -35, + "id": "2" + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/2.webp", + "preference": -34, + "id": "3" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/1.jpg", + "preference": -33, + "id": "4" + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/1.webp", + "preference": -32, + "id": "5" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/mq3.jpg", + "preference": -31, + "id": "6" + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/mq3.webp", + "preference": -30, + "id": "7" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/mq2.jpg", + "preference": -29, + "id": "8" + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/mq2.webp", + "preference": -28, + "id": "9" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/mq1.jpg", + "preference": -27, + "id": "10" + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/mq1.webp", + "preference": -26, + "id": "11" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hq3.jpg", + "preference": -25, + "id": "12" + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/hq3.webp", + "preference": -24, + "id": "13" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hq2.jpg", + "preference": -23, + "id": "14" + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/hq2.webp", + "preference": -22, + "id": "15" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hq1.jpg", + "preference": -21, + "id": "16" + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/hq1.webp", + "preference": -20, + "id": "17" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/sd3.jpg", + "preference": -19, + "id": "18" + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/sd3.webp", + "preference": -18, + "id": "19" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/sd2.jpg", + "preference": -17, + "id": "20" + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/sd2.webp", + "preference": -16, + "id": "21" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/sd1.jpg", + "preference": -15, + "id": "22" + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/sd1.webp", + "preference": -14, + "id": "23" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/default.jpg", + "preference": -13, + "id": "24" + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/default.webp", + "height": 90, + "width": 120, + "preference": -12, + "id": "25", + "resolution": "120x90" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/mqdefault.jpg", + "height": 180, + "width": 320, + "preference": -11, + "id": "26", + "resolution": "320x180" + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/mqdefault.webp", + "height": 180, + "width": 320, + "preference": -10, + "id": "27", + "resolution": "320x180" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/0.jpg", + "preference": -9, + "id": "28" + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/0.webp", + "preference": -8, + "id": "29" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDd2KtelLHaNSXrI9_5K-NvTscKNw", + "height": 94, + "width": 168, + "preference": -7, + "id": "30", + "resolution": "168x94" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg?sqp=-oaymwEbCMQBEG5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBUpEOOWUXWkNyijQuZ4UPzp2BE-w", + "height": 110, + "width": 196, + "preference": -7, + "id": "31", + "resolution": "196x110" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg?sqp=-oaymwEcCPYBEIoBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLBCyhr8AqpJ1SxKVU6SyK5ODJ_IpA", + "height": 138, + "width": 246, + "preference": -7, + "id": "32", + "resolution": "246x138" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLB_p0PncTtkrhaNDZtntrE3gKkoYw", + "height": 188, + "width": 336, + "preference": -7, + "id": "33", + "resolution": "336x188" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg", + "height": 360, + "width": 480, + "preference": -7, + "id": "34", + "resolution": "480x360" + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/hqdefault.webp", + "height": 360, + "width": 480, + "preference": -6, + "id": "35", + "resolution": "480x360" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/sddefault.jpg", + "height": 480, + "width": 640, + "preference": -5, + "id": "36", + "resolution": "640x480" + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/sddefault.webp", + "height": 480, + "width": 640, + "preference": -4, + "id": "37", + "resolution": "640x480" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hq720.jpg", + "preference": -3, + "id": "38" + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/hq720.webp", + "preference": -2, + "id": "39" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg", + "height": 720, + "width": 1280, + "preference": -1, + "id": "40", + "resolution": "1280x720" + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/maxresdefault.webp", + "height": 1080, + "width": 1920, + "preference": 0, + "id": "41", + "resolution": "1920x1080" + } + ], + "thumbnail": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/maxresdefault.webp", + "description": "The official video for \u201cNever Gonna Give You Up\u201d by Rick Astley\n\n\u2018Hold Me In Your Arms\u2019 \u2013 deluxe blue vinyl, 2CD and digital deluxe out 12th May 2023 Pre-order here \u2013 https://rick-astley.lnk.to/HMIYA2023ID\n\n\u201cNever Gonna Give You Up\u201d was a global smash on its release in July 1987, topping the charts in 25 countries including Rick\u2019s native UK and the US Billboard Hot 100. It also won the Brit Award for Best single in 1988. Stock Aitken and Waterman wrote and produced the track which was the lead-off single and lead track from Rick\u2019s debut LP \u201cWhenever You Need Somebody\u201d. The album was itself a UK number one and would go on to sell over 15 million copies worldwide.\n\nThe legendary video was directed by Simon West \u2013 who later went on to make Hollywood blockbusters such as Con Air, Lara Croft \u2013 Tomb Raider and The Expendables 2. The video passed the 1bn YouTube views milestone on 28 July 2021.\n\nSubscribe to the official Rick Astley YouTube channel: https://RickAstley.lnk.to/YTSubID\n\nFollow Rick Astley:\nFacebook: https://RickAstley.lnk.to/FBFollowID \nTwitter: https://RickAstley.lnk.to/TwitterID \nInstagram: https://RickAstley.lnk.to/InstagramID \nWebsite: https://RickAstley.lnk.to/storeID \nTikTok: https://RickAstley.lnk.to/TikTokID\n\nListen to Rick Astley:\nSpotify: https://RickAstley.lnk.to/SpotifyID \nApple Music: https://RickAstley.lnk.to/AppleMusicID \nAmazon Music: https://RickAstley.lnk.to/AmazonMusicID \nDeezer: https://RickAstley.lnk.to/DeezerID \n\nLyrics:\nWe\u2019re no strangers to love\nYou know the rules and so do I\nA full commitment\u2019s what I\u2019m thinking of\nYou wouldn\u2019t get this from any other guy\n\nI just wanna tell you how I\u2019m feeling\nGotta make you understand\n\nNever gonna give you up\nNever gonna let you down\nNever gonna run around and desert you\nNever gonna make you cry\nNever gonna say goodbye\nNever gonna tell a lie and hurt you\n\nWe\u2019ve known each other for so long\nYour heart\u2019s been aching but you\u2019re too shy to say it\nInside we both know what\u2019s been going on\nWe know the game and we\u2019re gonna play it\n\nAnd if you ask me how I\u2019m feeling\nDon\u2019t tell me you\u2019re too blind to see\n\nNever gonna give you up\nNever gonna let you down\nNever gonna run around and desert you\nNever gonna make you cry\nNever gonna say goodbye\nNever gonna tell a lie and hurt you\n\n#RickAstley #NeverGonnaGiveYouUp #WheneverYouNeedSomebody #OfficialMusicVideo", + "channel_id": "UCuAXFkgsw1L7xaCfnd5JJOw", + "channel_url": "https://www.youtube.com/channel/UCuAXFkgsw1L7xaCfnd5JJOw", + "duration": 212, + "view_count": 1447363306, + "average_rating": null, + "age_limit": 0, + "webpage_url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + "categories": ["Music"], + "tags": [ + "rick astley", + "Never Gonna Give You Up", + "nggyu", + "never gonna give you up lyrics", + "rick rolled", + "Rick Roll", + "rick astley official", + "rickrolled", + "Fortnite song", + "Fortnite event", + "Fortnite dance", + "fortnite never gonna give you up", + "rick roll", + "rickrolling", + "rick rolling", + "never gonna give you up", + "80s music", + "rick astley new", + "animated video", + "rickroll", + "meme songs", + "never gonna give u up lyrics", + "Rick Astley 2022", + "never gonna let you down", + "animated", + "rick rolls 2022", + "never gonna give you up karaoke" + ], + "playable_in_embed": true, + "live_status": "not_live", + "release_timestamp": null, + "_format_sort_fields": [ + "quality", + "res", + "fps", + "hdr:12", + "source", + "vcodec:vp9.2", + "channels", + "acodec", + "lang", + "proto" + ], + "automatic_captions": {}, + "subtitles": {}, + "comment_count": 2200000, + "chapters": null, + "heatmap": [], + "like_count": 16843101, + "channel": "Rick Astley", + "channel_follower_count": 3870000, + "channel_is_verified": true, + "uploader": "Rick Astley", + "uploader_id": "@RickAstleyYT", + "uploader_url": "https://www.youtube.com/@RickAstleyYT", + "upload_date": "20091025", + "availability": "public", + "original_url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + "webpage_url_basename": "watch", + "webpage_url_domain": "youtube.com", + "extractor": "youtube", + "extractor_key": "Youtube", + "playlist": null, + "playlist_index": null, + "display_id": "dQw4w9WgXcQ", + "fulltitle": "Rick Astley - Never Gonna Give You Up (Official Music Video)", + "duration_string": "3:32", + "is_live": false, + "was_live": false, + "requested_subtitles": null, + "_has_drm": null, + "epoch": 1694783669, + "requested_formats": [ + { + "format_id": "616", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805268/ei/tFgEZcu0DoOD-gaqg47wBA/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/616/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D99471214%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D356%3Blmt%3D1694043438471036/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,29/mn/sn-5hne6nzy,sn-5hnekn7k/ms/au,rdu/mv/m/mvi/3/pl/22/initcwndbps/1957500/vprv/1/playlist_type/DVR/dover/13/txp/4532434/mt/1694783146/fvip/2/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRQIhALAASH0_ZDQQoMA82qWNCXSHPZ0bb9TQldIs7AAxktiiAiASA5bQy7IAa6NwdGIOpfye5OgcY_BNuo0WgSdh84tosw%3D%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRgIhAIsDcLGH8KJpQpBgyJ5VWlDxfr75HyO8hMSVS9v7nRu4AiEA2xjtLZOzeNFoJlxwCsH3YqsUQt-BF_4gikhi_P4FbBc%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805268/ei/tFgEZcu0DoOD-gaqg47wBA/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C29/mn/sn-5hne6nzy%2Csn-5hnekn7k/ms/au%2Crdu/mv/m/mvi/3/pl/22/tx/24388770/txs/24388767%2C24388768%2C24388769%2C24388770%2C24388771%2C24388772%2C24388773/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1957500/vprv/1/go/1/mt/1694783146/fvip/2/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRQIhAL8hNlWRffc33Ibzb-OCH34lon8WNpNiKHeUFUFMzvlDAiAr5e33CzsKEX8k0MiF68H_7xmDW2b6HQSW0uiiFr2Rxg%3D%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRAIgF4H6KcG8d2n_e4oe9m5iMfJFj7-zvjFtzJfWCByyfVwCIDLMnTkaV3Szw249SnaNqBAw5vMO_DCwPJZkqzdT0P5p/file/index.m3u8", + "tbr": 5704.254, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 9, + "has_drm": false, + "width": 1920, + "height": 1080, + "vcodec": "vp09.00.40.08", + "acodec": "none", + "dynamic_range": "SDR", + "source_preference": 99, + "format_note": "Premium", + "resolution": "1920x1080", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 5704.254, + "format": "616 - 1920x1080 (Premium)" + }, + { + "asr": 48000, + "filesize": 3437753, + "format_id": "251", + "format_note": "medium", + "source_preference": -1, + "fps": null, + "audio_channels": 2, + "height": null, + "quality": 3.0, + "has_drm": false, + "tbr": 129.689, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805268&ei=tFgEZaHmFN2Px_AP2tSt2AQ&ip=45.93.75.130&id=o-AEj4DudORoGviGzjggo2mjXrQpjRh8L2BrOU-wekY859&itag=251&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hne6nzy%2Csn-5hnekn7k&ms=au%2Crdu&mv=m&mvi=3&pl=22&initcwndbps=1957500&spc=UWF9f7_CV3gS4VV2VFq7hgxtUAyOlog&vprv=1&svpuc=1&mime=audio%2Fwebm&gir=yes&clen=3437753&dur=212.061&lmt=1694040798752663&mt=1694783146&fvip=2&keepalive=yes&fexp=24007246&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhAL5gfx40AmiGAp0vSd50hypQlBE4W-Qo5iiD95oYKtH-AiEAybTs2BuunUUrhfMdyEcuPxwPC8ww_-p-danCp9uAArc%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIgRCGi20K-ZvdukYkBZOidcHpGPUpIBOkw-jZGEncsKQECIQC5h-rCfQhDTQFqocOTtQXcNZVA54oIqjweF0mN5GpzFA%3D%3D", + "width": null, + "language": "en", + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "none", + "acodec": "opus", + "dynamic_range": null, + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "audio only", + "aspect_ratio": null, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "webm", + "video_ext": "none", + "vbr": 0, + "abr": 129.689, + "format": "251 - audio only (medium)" + } + ], + "format": "616 - 1920x1080 (Premium)+251 - audio only (medium)", + "format_id": "616+251", + "ext": "webm", + "protocol": "m3u8_native+https", + "language": "en", + "format_note": "Premium+medium", + "filesize_approx": 3437753, + "tbr": 5833.943, + "width": 1920, + "height": 1080, + "resolution": "1920x1080", + "fps": 25.0, + "dynamic_range": "SDR", + "vcodec": "vp09.00.40.08", + "vbr": 5704.254, + "stretched_ratio": null, + "aspect_ratio": 1.78, + "acodec": "opus", + "abr": 129.689, + "asr": 48000, + "audio_channels": 2 +} diff --git a/tests/components/media_extractor/fixtures/youtube_empty_playlist_info.json b/tests/components/media_extractor/fixtures/youtube_empty_playlist_info.json new file mode 100644 index 00000000000..ceec0d28db2 --- /dev/null +++ b/tests/components/media_extractor/fixtures/youtube_empty_playlist_info.json @@ -0,0 +1,49 @@ +{ + "id": "PLZ4DbyIWUwCq4V8bIEa8jm2ozHZVuREJO", + "title": "Very important videos", + "availability": "public", + "channel_follower_count": null, + "description": "Not original", + "tags": [], + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/hqdefault.jpg?sqp=-oaymwEwCKgBEF5IWvKriqkDIwgBFQAAiEIYAfABAfgB3gOAAugCigIMCAAQARg8IGUoPzAP&rs=AOn4CLDBCH5IQ0obogxXhAzIH8pE0d7r1Q", + "height": 94, + "width": 168 + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/hqdefault.jpg?sqp=-oaymwEwCMQBEG5IWvKriqkDIwgBFQAAiEIYAfABAfgB3gOAAugCigIMCAAQARg8IGUoPzAP&rs=AOn4CLAybhgn-CoPMjBE-0VfBDqvy0jyOQ", + "height": 110, + "width": 196 + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/hqdefault.jpg?sqp=-oaymwExCPYBEIoBSFryq4qpAyMIARUAAIhCGAHwAQH4Ad4DgALoAooCDAgAEAEYPCBlKD8wDw==&rs=AOn4CLDutIdjr5zTE9G78eWf83-mGXYnUA", + "height": 138, + "width": 246 + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/hqdefault.jpg?sqp=-oaymwExCNACELwBSFryq4qpAyMIARUAAIhCGAHwAQH4Ad4DgALoAooCDAgAEAEYPCBlKD8wDw==&rs=AOn4CLD2884fHuvAv8ysHA48LD3uArB6bA", + "height": 188, + "width": 336 + } + ], + "modified_date": "20230813", + "view_count": 5680730, + "playlist_count": 5, + "channel": "Armand314", + "channel_id": "UChOLuQpsxxmJiJUeSU2tSTw", + "uploader_id": "@Armand314", + "uploader": "Armand314", + "channel_url": "https://www.youtube.com/channel/UChOLuQpsxxmJiJUeSU2tSTw", + "uploader_url": "https://www.youtube.com/@Armand314", + "_type": "playlist", + "entries": [], + "extractor_key": "YoutubeTab", + "extractor": "youtube:tab", + "webpage_url": "https://www.youtube.com/playlist?list=PLZ4DbyIWUwCq4V8bIEa8jm2ozHZVuREJO", + "original_url": "https://www.youtube.com/playlist?list=PLZ4DbyIWUwCq4V8bIEa8jm2ozHZVuREJO", + "webpage_url_basename": "playlist", + "webpage_url_domain": "youtube.com", + "heatmap": [], + "automatic_captions": {} +} diff --git a/tests/components/media_extractor/fixtures/youtube_playlist_info.json b/tests/components/media_extractor/fixtures/youtube_playlist_info.json new file mode 100644 index 00000000000..c1d39365387 --- /dev/null +++ b/tests/components/media_extractor/fixtures/youtube_playlist_info.json @@ -0,0 +1,265 @@ +{ + "id": "PLZ4DbyIWUwCq4V8bIEa8jm2ozHZVuREJP", + "title": "Very important videos", + "availability": "public", + "channel_follower_count": null, + "description": "Not original", + "tags": [], + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/hqdefault.jpg?sqp=-oaymwEwCKgBEF5IWvKriqkDIwgBFQAAiEIYAfABAfgB3gOAAugCigIMCAAQARg8IGUoPzAP&rs=AOn4CLDBCH5IQ0obogxXhAzIH8pE0d7r1Q", + "height": 94, + "width": 168 + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/hqdefault.jpg?sqp=-oaymwEwCMQBEG5IWvKriqkDIwgBFQAAiEIYAfABAfgB3gOAAugCigIMCAAQARg8IGUoPzAP&rs=AOn4CLAybhgn-CoPMjBE-0VfBDqvy0jyOQ", + "height": 110, + "width": 196 + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/hqdefault.jpg?sqp=-oaymwExCPYBEIoBSFryq4qpAyMIARUAAIhCGAHwAQH4Ad4DgALoAooCDAgAEAEYPCBlKD8wDw==&rs=AOn4CLDutIdjr5zTE9G78eWf83-mGXYnUA", + "height": 138, + "width": 246 + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/hqdefault.jpg?sqp=-oaymwExCNACELwBSFryq4qpAyMIARUAAIhCGAHwAQH4Ad4DgALoAooCDAgAEAEYPCBlKD8wDw==&rs=AOn4CLD2884fHuvAv8ysHA48LD3uArB6bA", + "height": 188, + "width": 336 + } + ], + "modified_date": "20230813", + "view_count": 5680730, + "playlist_count": 5, + "channel": "Armand314", + "channel_id": "UChOLuQpsxxmJiJUeSU2tSTw", + "uploader_id": "@Armand314", + "uploader": "Armand314", + "channel_url": "https://www.youtube.com/channel/UChOLuQpsxxmJiJUeSU2tSTw", + "uploader_url": "https://www.youtube.com/@Armand314", + "_type": "playlist", + "entries": [ + { + "_type": "url", + "ie_key": "Youtube", + "id": "q6EoRBvdVPQ", + "url": "https://www.youtube.com/watch?v=q6EoRBvdVPQ", + "title": "Yee", + "description": null, + "duration": 10, + "channel_id": "UC-fD_qwTEQQ1L-MUWx_mNvg", + "channel": "revergo", + "channel_url": "https://www.youtube.com/channel/UC-fD_qwTEQQ1L-MUWx_mNvg", + "uploader": "revergo", + "uploader_id": "@revergo", + "uploader_url": "https://www.youtube.com/@revergo", + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/hqdefault.jpg?sqp=-oaymwE1CKgBEF5IVfKriqkDKAgBFQAAiEIYAXABwAEG8AEB-AHeA4AC6AKKAgwIABABGDwgZSg_MA8=&rs=AOn4CLAJYg16HMBdEsv9lYBJyNqA5G3anQ", + "height": 94, + "width": 168 + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/hqdefault.jpg?sqp=-oaymwE1CMQBEG5IVfKriqkDKAgBFQAAiEIYAXABwAEG8AEB-AHeA4AC6AKKAgwIABABGDwgZSg_MA8=&rs=AOn4CLAgCNP9UuQas-D59hHHM-RqkUvA6g", + "height": 110, + "width": 196 + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/hqdefault.jpg?sqp=-oaymwE2CPYBEIoBSFXyq4qpAygIARUAAIhCGAFwAcABBvABAfgB3gOAAugCigIMCAAQARg8IGUoPzAP&rs=AOn4CLCTWaY5897XxhcpRyVtGQQNuMHfTg", + "height": 138, + "width": 246 + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/hqdefault.jpg?sqp=-oaymwE2CNACELwBSFXyq4qpAygIARUAAIhCGAFwAcABBvABAfgB3gOAAugCigIMCAAQARg8IGUoPzAP&rs=AOn4CLCeS6NC75yTYvyP4DsehZ3oXNuxMQ", + "height": 188, + "width": 336 + } + ], + "timestamp": null, + "release_timestamp": null, + "availability": null, + "view_count": 96000000, + "live_status": null, + "channel_is_verified": null + }, + { + "_type": "url", + "ie_key": "Youtube", + "id": "8YWl7tDGUPA", + "url": "https://www.youtube.com/watch?v=8YWl7tDGUPA", + "title": "color red", + "description": null, + "duration": 17, + "channel_id": "UCbYMTn6xKV0IKshL4pRCV3g", + "channel": "Alex Jimenez", + "channel_url": "https://www.youtube.com/channel/UCbYMTn6xKV0IKshL4pRCV3g", + "uploader": "Alex Jimenez", + "uploader_id": "@alexjimenez1237", + "uploader_url": "https://www.youtube.com/@alexjimenez1237", + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/8YWl7tDGUPA/hqdefault.jpg?sqp=-oaymwE1CKgBEF5IVfKriqkDKAgBFQAAiEIYAXABwAEG8AEB-AG2BIACwAKKAgwIABABGGUgXShUMA8=&rs=AOn4CLBqzngIx-4i_HFvqloetUfeN8yrYw", + "height": 94, + "width": 168 + }, + { + "url": "https://i.ytimg.com/vi/8YWl7tDGUPA/hqdefault.jpg?sqp=-oaymwE1CMQBEG5IVfKriqkDKAgBFQAAiEIYAXABwAEG8AEB-AG2BIACwAKKAgwIABABGGUgXShUMA8=&rs=AOn4CLB7mWPQmdL2QBLxTHhrgbFj2jFaCg", + "height": 110, + "width": 196 + }, + { + "url": "https://i.ytimg.com/vi/8YWl7tDGUPA/hqdefault.jpg?sqp=-oaymwE2CPYBEIoBSFXyq4qpAygIARUAAIhCGAFwAcABBvABAfgBtgSAAsACigIMCAAQARhlIF0oVDAP&rs=AOn4CLA9YAIO3g_DnClsuc5LjMQn4O9ZQQ", + "height": 138, + "width": 246 + }, + { + "url": "https://i.ytimg.com/vi/8YWl7tDGUPA/hqdefault.jpg?sqp=-oaymwE2CNACELwBSFXyq4qpAygIARUAAIhCGAFwAcABBvABAfgBtgSAAsACigIMCAAQARhlIF0oVDAP&rs=AOn4CLDPHY6aG08hlTJMlc-LJt9ywtpWEg", + "height": 188, + "width": 336 + } + ], + "timestamp": null, + "release_timestamp": null, + "availability": null, + "view_count": 30000000, + "live_status": null, + "channel_is_verified": null + }, + { + "_type": "url", + "ie_key": "Youtube", + "id": "6bnanI9jXps", + "url": "https://www.youtube.com/watch?v=6bnanI9jXps", + "title": "Terrible Mall Commercial", + "description": null, + "duration": 31, + "channel_id": "UCLmnB20wsih9F5N0o5K0tig", + "channel": "quantim", + "channel_url": "https://www.youtube.com/channel/UCLmnB20wsih9F5N0o5K0tig", + "uploader": "quantim", + "uploader_id": "@Potatoflesh", + "uploader_url": "https://www.youtube.com/@Potatoflesh", + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/6bnanI9jXps/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAsyI0ZJA9STG8vlSdRkKk55ls5Dg", + "height": 94, + "width": 168 + }, + { + "url": "https://i.ytimg.com/vi/6bnanI9jXps/hqdefault.jpg?sqp=-oaymwEbCMQBEG5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLD2bZ9S8AB4UGsZlx_8TjBoL72enA", + "height": 110, + "width": 196 + }, + { + "url": "https://i.ytimg.com/vi/6bnanI9jXps/hqdefault.jpg?sqp=-oaymwEcCPYBEIoBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLCKNlgvl_7lKoFq8vyDYZRtTs4woA", + "height": 138, + "width": 246 + }, + { + "url": "https://i.ytimg.com/vi/6bnanI9jXps/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLBeZv8F8IyICmKD9qjo9pTMJmM8ug", + "height": 188, + "width": 336 + } + ], + "timestamp": null, + "release_timestamp": null, + "availability": null, + "view_count": 26000000, + "live_status": null, + "channel_is_verified": null + }, + { + "_type": "url", + "ie_key": "Youtube", + "id": "SBeYzoQPbu8", + "url": "https://www.youtube.com/watch?v=SBeYzoQPbu8", + "title": "name a yellow fruit", + "description": null, + "duration": 4, + "channel_id": "UCkRDJpXb96HrsdDSF8AwysA", + "channel": "DaRkMaGiCiAn5009", + "channel_url": "https://www.youtube.com/channel/UCkRDJpXb96HrsdDSF8AwysA", + "uploader": "DaRkMaGiCiAn5009", + "uploader_id": "@DaRkMaGiCiAn5009", + "uploader_url": "https://www.youtube.com/@DaRkMaGiCiAn5009", + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/SBeYzoQPbu8/hqdefault.jpg?sqp=-oaymwE1CKgBEF5IVfKriqkDKAgBFQAAiEIYAXABwAEG8AEB-AHmAoAC6AKKAgwIABABGGUgUShHMA8=&rs=AOn4CLAZhgooxUn_fTi4K4OnWOcObof3TA", + "height": 94, + "width": 168 + }, + { + "url": "https://i.ytimg.com/vi/SBeYzoQPbu8/hqdefault.jpg?sqp=-oaymwE1CMQBEG5IVfKriqkDKAgBFQAAiEIYAXABwAEG8AEB-AHmAoAC6AKKAgwIABABGGUgUShHMA8=&rs=AOn4CLApcEdGLsf088qGyT2ITBRMD-toAg", + "height": 110, + "width": 196 + }, + { + "url": "https://i.ytimg.com/vi/SBeYzoQPbu8/hqdefault.jpg?sqp=-oaymwE2CPYBEIoBSFXyq4qpAygIARUAAIhCGAFwAcABBvABAfgB5gKAAugCigIMCAAQARhlIFEoRzAP&rs=AOn4CLBv0kiYaUPOX8JIg1rASAUhtxBxxA", + "height": 138, + "width": 246 + }, + { + "url": "https://i.ytimg.com/vi/SBeYzoQPbu8/hqdefault.jpg?sqp=-oaymwE2CNACELwBSFXyq4qpAygIARUAAIhCGAFwAcABBvABAfgB5gKAAugCigIMCAAQARhlIFEoRzAP&rs=AOn4CLBXN-tNsOG3AzyPZ7UgOuwS7mEs7g", + "height": 188, + "width": 336 + } + ], + "timestamp": null, + "release_timestamp": null, + "availability": null, + "view_count": 14000000, + "live_status": null, + "channel_is_verified": null + }, + { + "_type": "url", + "ie_key": "Youtube", + "id": "ixQkcuZhXg8", + "url": "https://www.youtube.com/watch?v=ixQkcuZhXg8", + "title": "The moment an old lady questions her own sanity", + "description": null, + "duration": 31, + "channel_id": "UCVsKh1uG6_T0o8nYrtmqmyA", + "channel": "Marcus", + "channel_url": "https://www.youtube.com/channel/UCVsKh1uG6_T0o8nYrtmqmyA", + "uploader": "Marcus", + "uploader_id": "@Marcuskb92", + "uploader_url": "https://www.youtube.com/@Marcuskb92", + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/ixQkcuZhXg8/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCelxa91oGbhu8LhhLkcSiHF7YSGg", + "height": 94, + "width": 168 + }, + { + "url": "https://i.ytimg.com/vi/ixQkcuZhXg8/hqdefault.jpg?sqp=-oaymwEbCMQBEG5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAAJnPf2Rl67uaRBR2lgkkBojkTiw", + "height": 110, + "width": 196 + }, + { + "url": "https://i.ytimg.com/vi/ixQkcuZhXg8/hqdefault.jpg?sqp=-oaymwEcCPYBEIoBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLBNoK36znIiRCoNE5tKnfF1oYXJ8A", + "height": 138, + "width": 246 + }, + { + "url": "https://i.ytimg.com/vi/ixQkcuZhXg8/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLCA_twfGS2acx005yqJgAYOT40qvQ", + "height": 188, + "width": 336 + } + ], + "timestamp": null, + "release_timestamp": null, + "availability": null, + "view_count": 17000000, + "live_status": null, + "channel_is_verified": null + } + ], + "extractor_key": "YoutubeTab", + "extractor": "youtube:tab", + "webpage_url": "https://www.youtube.com/playlist?list=PLZ4DbyIWUwCq4V8bIEa8jm2ozHZVuREJP", + "original_url": "https://www.youtube.com/playlist?list=PLZ4DbyIWUwCq4V8bIEa8jm2ozHZVuREJP", + "webpage_url_basename": "playlist", + "webpage_url_domain": "youtube.com", + "heatmap": [], + "automatic_captions": {} +} diff --git a/tests/components/media_extractor/fixtures/youtube_playlist_result.json b/tests/components/media_extractor/fixtures/youtube_playlist_result.json new file mode 100644 index 00000000000..ae5072ecbb4 --- /dev/null +++ b/tests/components/media_extractor/fixtures/youtube_playlist_result.json @@ -0,0 +1,1351 @@ +{ + "id": "q6EoRBvdVPQ", + "title": "Yee", + "formats": [ + { + "format_id": "sb0", + "format_note": "storyboard", + "ext": "mhtml", + "protocol": "mhtml", + "acodec": "none", + "vcodec": "none", + "url": "https://i.ytimg.com/sb/q6EoRBvdVPQ/storyboard3_L0/default.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgi3_YjvBQ==&sigh=rs$AOn4CLDitSYn-lYL95DTEPMg_2O_KiuBDg", + "width": 48, + "height": 27, + "fps": 11.11111111111111, + "rows": 10, + "columns": 10, + "fragments": [ + { + "url": "https://i.ytimg.com/sb/q6EoRBvdVPQ/storyboard3_L0/default.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgi3_YjvBQ==&sigh=rs$AOn4CLDitSYn-lYL95DTEPMg_2O_KiuBDg", + "duration": 9.0 + } + ], + "resolution": "48x27", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.93 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "none", + "video_ext": "none", + "vbr": 0, + "abr": 0, + "tbr": null, + "format": "sb0 - 48x27 (storyboard)" + }, + { + "format_id": "233", + "format_note": "Default", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694818322/ei/sosEZcmcMdGVgQeatIDABA/ip/45.93.75.130/id/aba128441bdd54f4/itag/233/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/goi/133/sgoap/clen%3D56681%3Bdur%3D9.148%3Bgir%3Dyes%3Bitag%3D139%3Blmt%3D1660945484047785/hls_chunk_host/rr2---sn-5hne6nzk.googlevideo.com/mh/6Q/mm/31,29/mn/sn-5hne6nzk,sn-5hnednss/ms/au,rdu/mv/m/mvi/2/pl/22/initcwndbps/1868750/vprv/1/playlist_type/DVR/dover/13/txp/4432434/mt/1694796392/fvip/5/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,goi,sgoap,vprv,playlist_type/sig/AOq0QJ8wRgIhAIIUXW7l2Q1N9xS5I-AR2telnZyDaJQaftrqcKgaFLzRAiEA0_31fEW0eHdGDCKTUvaPCaLOVcSzhbWC1GvuwMa0jeU%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRgIhAK2cOCSMsdA4Mw-otp1P8usOJv1VZQfypOVUfnc-U4JVAiEAza1YIXOviatFdW9jc95da8a-0TbKz7on-RMKz8jJS20%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694818322/ei/sosEZcmcMdGVgQeatIDABA/ip/45.93.75.130/id/aba128441bdd54f4/source/youtube/requiressl/yes/playback_host/rr2---sn-5hne6nzk.googlevideo.com/mh/6Q/mm/31%2C29/mn/sn-5hne6nzk%2Csn-5hnednss/ms/au%2Crdu/mv/m/mvi/2/pl/22/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1868750/vprv/1/go/1/mt/1694796392/fvip/5/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRgIhAPjgJ5L6c_sfFyR2FFR1BaUJo8RuGjhA9_1wOND8AO3GAiEA41COxaqltPNEco0cXZsJoBy8GMUqP95BLUdTfJRNRJE%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRgIhANAG8hyS_myLc16a28Hj3fv5g_3IRWYVGBTZmhXL6wniAiEAvOFf1xoAySEDlUMNJ9ir0-sz4nZ6niJSZVtxlw78e5o%3D/file/index.m3u8", + "language": "es", + "ext": "mp4", + "protocol": "m3u8_native", + "preference": null, + "quality": -1, + "has_drm": false, + "vcodec": "none", + "source_preference": -1, + "resolution": "audio only", + "aspect_ratio": null, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.93 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "mp4", + "video_ext": "none", + "vbr": 0, + "abr": null, + "tbr": null, + "format": "233 - audio only (Default)" + }, + { + "format_id": "234", + "format_note": "Default", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694818322/ei/sosEZcmcMdGVgQeatIDABA/ip/45.93.75.130/id/aba128441bdd54f4/itag/234/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/goi/133/sgoap/clen%3D147770%3Bdur%3D9.055%3Bgir%3Dyes%3Bitag%3D140%3Blmt%3D1660945484356876/hls_chunk_host/rr2---sn-5hne6nzk.googlevideo.com/mh/6Q/mm/31,29/mn/sn-5hne6nzk,sn-5hnednss/ms/au,rdu/mv/m/mvi/2/pl/22/initcwndbps/1868750/vprv/1/playlist_type/DVR/dover/13/txp/4432434/mt/1694796392/fvip/5/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,goi,sgoap,vprv,playlist_type/sig/AOq0QJ8wRQIhAMABt0U4sY_mybpIH7LkXKqubiCQ7uj0AdysCPT7H6DHAiBzn6BY18K0lWocHbDAccjGPFdXr6ZzCnUmbHwO80WxuQ%3D%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRgIhALtyE63XOF1RNgq6rBt7xE1ZL6mVPNw3V-o5pHdDiX95AiEAk0hvBk4F_5lek6pCxPvQ-EyvTsQiJJA_I6pVWXFTxhQ%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694818322/ei/sosEZcmcMdGVgQeatIDABA/ip/45.93.75.130/id/aba128441bdd54f4/source/youtube/requiressl/yes/playback_host/rr2---sn-5hne6nzk.googlevideo.com/mh/6Q/mm/31%2C29/mn/sn-5hne6nzk%2Csn-5hnednss/ms/au%2Crdu/mv/m/mvi/2/pl/22/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1868750/vprv/1/go/1/mt/1694796392/fvip/5/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRgIhAPjgJ5L6c_sfFyR2FFR1BaUJo8RuGjhA9_1wOND8AO3GAiEA41COxaqltPNEco0cXZsJoBy8GMUqP95BLUdTfJRNRJE%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRgIhANAG8hyS_myLc16a28Hj3fv5g_3IRWYVGBTZmhXL6wniAiEAvOFf1xoAySEDlUMNJ9ir0-sz4nZ6niJSZVtxlw78e5o%3D/file/index.m3u8", + "language": "es", + "ext": "mp4", + "protocol": "m3u8_native", + "preference": null, + "quality": -1, + "has_drm": false, + "vcodec": "none", + "source_preference": -1, + "resolution": "audio only", + "aspect_ratio": null, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.93 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "mp4", + "video_ext": "none", + "vbr": 0, + "abr": null, + "tbr": null, + "format": "234 - audio only (Default)" + }, + { + "asr": 22050, + "filesize": 56681, + "format_id": "139", + "format_note": "low", + "source_preference": -1, + "fps": null, + "audio_channels": 2, + "height": null, + "quality": 2.0, + "has_drm": false, + "tbr": 49.562, + "url": "https://rr2---sn-5hne6nzk.googlevideo.com/videoplayback?expire=1694818322&ei=sosEZcmcMdGVgQeatIDABA&ip=45.93.75.130&id=o-ANZGIl8-Lo8u8x_fU-l5VosaHna8zx8_6Ab0CCT-vzjQ&itag=139&source=youtube&requiressl=yes&mh=6Q&mm=31%2C29&mn=sn-5hne6nzk%2Csn-5hnednss&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=1868750&vprv=1&svpuc=1&mime=audio%2Fmp4&gir=yes&clen=56681&dur=9.148&lmt=1660945484047785&mt=1694796392&fvip=5&keepalive=yes&fexp=24007246&c=IOS&txp=4432434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIgMTEq7iYqWrL3WZ7ptBG-QvTumAooMR7hknyteXrtpAYCIQDVM_SjwgF8qOQJHHVx35PsreSlxcfmNIBXQkn5DcEb_A%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIhAN9Und25H4_kUjcAoZ_LVv0lAVTnPDkI-t5f7JJBA_jhAiAsXrF-84K_iBGiTwIwXS_eOlp5JPXxLEhyDj_cB8zdxQ%3D%3D", + "width": null, + "language": "es", + "language_preference": -1, + "preference": null, + "ext": "m4a", + "vcodec": "none", + "acodec": "mp4a.40.5", + "dynamic_range": null, + "container": "m4a_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "audio only", + "aspect_ratio": null, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.93 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "m4a", + "video_ext": "none", + "vbr": 0, + "abr": 49.562, + "format": "139 - audio only (low)" + }, + { + "asr": 48000, + "filesize": 51092, + "format_id": "249", + "format_note": "low", + "source_preference": -1, + "fps": null, + "audio_channels": 2, + "height": null, + "quality": 2.0, + "has_drm": false, + "tbr": 45.309, + "url": "https://rr2---sn-5hne6nzk.googlevideo.com/videoplayback?expire=1694818322&ei=sosEZfXrN8mrx_APirihiAo&ip=45.93.75.130&id=o-AK8fF61bmcIHhl_2kv1XxpCtdRixUPDqG0y6aunrwcZa&itag=249&source=youtube&requiressl=yes&mh=6Q&mm=31%2C29&mn=sn-5hne6nzk%2Csn-5hnednss&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=1868750&spc=UWF9f0JgCQlRLpY93JZnveUdoMCdkmY&vprv=1&svpuc=1&mime=audio%2Fwebm&gir=yes&clen=51092&dur=9.021&lmt=1660945486064674&mt=1694796392&fvip=5&keepalive=yes&fexp=24007246&beids=24350017&c=ANDROID&txp=4432434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIhAOj8MU8sihogln9ayHPLk2AyHHAFTzzv4C7gmAKZ5BJNAiA3o-GaC68jHxU3BlAcffo43FuRhkiR7BGrTZyJEZJ_uQ%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAKy1C4o9YUyi7o2_03UfJ8n8vXWgF4t8zB-4FXiAtJ5uAiEAh2chtgFo6quycJIs1kagkaa_AAQbEFrnFU1xEUDEqp4%3D", + "width": null, + "language": "es", + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "none", + "acodec": "opus", + "dynamic_range": null, + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "audio only", + "aspect_ratio": null, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.93 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "webm", + "video_ext": "none", + "vbr": 0, + "abr": 45.309, + "format": "249 - audio only (low)" + }, + { + "asr": 48000, + "filesize": 64622, + "format_id": "250", + "format_note": "low", + "source_preference": -1, + "fps": null, + "audio_channels": 2, + "height": null, + "quality": 2.0, + "has_drm": false, + "tbr": 57.308, + "url": "https://rr2---sn-5hne6nzk.googlevideo.com/videoplayback?expire=1694818322&ei=sosEZfXrN8mrx_APirihiAo&ip=45.93.75.130&id=o-AK8fF61bmcIHhl_2kv1XxpCtdRixUPDqG0y6aunrwcZa&itag=250&source=youtube&requiressl=yes&mh=6Q&mm=31%2C29&mn=sn-5hne6nzk%2Csn-5hnednss&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=1868750&spc=UWF9f0JgCQlRLpY93JZnveUdoMCdkmY&vprv=1&svpuc=1&mime=audio%2Fwebm&gir=yes&clen=64622&dur=9.021&lmt=1660945475759533&mt=1694796392&fvip=5&keepalive=yes&fexp=24007246&beids=24350017&c=ANDROID&txp=4432434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRAIgCXSqHdL6u2fA4tpsNsevT3YQDR9HlMlFeWYQYBnbbp0CIBfbNs3IVtOe7kB2JpPFVo_XxwKwbu67JjkutEadCgWD&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAKy1C4o9YUyi7o2_03UfJ8n8vXWgF4t8zB-4FXiAtJ5uAiEAh2chtgFo6quycJIs1kagkaa_AAQbEFrnFU1xEUDEqp4%3D", + "width": null, + "language": "es", + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "none", + "acodec": "opus", + "dynamic_range": null, + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "audio only", + "aspect_ratio": null, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.93 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "webm", + "video_ext": "none", + "vbr": 0, + "abr": 57.308, + "format": "250 - audio only (low)" + }, + { + "asr": 44100, + "filesize": 147770, + "format_id": "140", + "format_note": "medium", + "source_preference": -1, + "fps": null, + "audio_channels": 2, + "height": null, + "quality": 3.0, + "has_drm": false, + "tbr": 130.538, + "url": "https://rr2---sn-5hne6nzk.googlevideo.com/videoplayback?expire=1694818322&ei=sosEZcmcMdGVgQeatIDABA&ip=45.93.75.130&id=o-ANZGIl8-Lo8u8x_fU-l5VosaHna8zx8_6Ab0CCT-vzjQ&itag=140&source=youtube&requiressl=yes&mh=6Q&mm=31%2C29&mn=sn-5hne6nzk%2Csn-5hnednss&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=1868750&vprv=1&svpuc=1&mime=audio%2Fmp4&gir=yes&clen=147770&dur=9.055&lmt=1660945484356876&mt=1694796392&fvip=5&keepalive=yes&fexp=24007246&c=IOS&txp=4432434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRAIgATfgfWIf5K3rL4Q_uA_9Cqx1Xq4viIABoTGmIZZ6dHMCID_TsF_fiNjYH6yVLdOmu7U5uSyxCQC2NvNymtg5aseO&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIhAN9Und25H4_kUjcAoZ_LVv0lAVTnPDkI-t5f7JJBA_jhAiAsXrF-84K_iBGiTwIwXS_eOlp5JPXxLEhyDj_cB8zdxQ%3D%3D", + "width": null, + "language": "es", + "language_preference": -1, + "preference": null, + "ext": "m4a", + "vcodec": "none", + "acodec": "mp4a.40.2", + "dynamic_range": null, + "container": "m4a_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "audio only", + "aspect_ratio": null, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.93 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "m4a", + "video_ext": "none", + "vbr": 0, + "abr": 130.538, + "format": "140 - audio only (medium)" + }, + { + "asr": 48000, + "filesize": 123202, + "format_id": "251", + "format_note": "medium", + "source_preference": -1, + "fps": null, + "audio_channels": 2, + "height": null, + "quality": 3.0, + "has_drm": false, + "tbr": 109.257, + "url": "https://rr2---sn-5hne6nzk.googlevideo.com/videoplayback?expire=1694818322&ei=sosEZfXrN8mrx_APirihiAo&ip=45.93.75.130&id=o-AK8fF61bmcIHhl_2kv1XxpCtdRixUPDqG0y6aunrwcZa&itag=251&source=youtube&requiressl=yes&mh=6Q&mm=31%2C29&mn=sn-5hne6nzk%2Csn-5hnednss&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=1868750&spc=UWF9f0JgCQlRLpY93JZnveUdoMCdkmY&vprv=1&svpuc=1&mime=audio%2Fwebm&gir=yes&clen=123202&dur=9.021&lmt=1660945472183333&mt=1694796392&fvip=5&keepalive=yes&fexp=24007246&beids=24350017&c=ANDROID&txp=4432434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIhAMvJsObaszrBWjSOzW_wD0jXOBJTmZsU0WpEG2pSqXaHAiBxmFJUftWY3sPtSdbaSoTYHfdHxOYHupAA85TROnKjIg%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAKy1C4o9YUyi7o2_03UfJ8n8vXWgF4t8zB-4FXiAtJ5uAiEAh2chtgFo6quycJIs1kagkaa_AAQbEFrnFU1xEUDEqp4%3D", + "width": null, + "language": "es", + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "none", + "acodec": "opus", + "dynamic_range": null, + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "audio only", + "aspect_ratio": null, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.93 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "webm", + "video_ext": "none", + "vbr": 0, + "abr": 109.257, + "format": "251 - audio only (medium)" + }, + { + "asr": 22050, + "filesize": 87229, + "format_id": "17", + "format_note": "144p", + "source_preference": -1, + "fps": 7, + "audio_channels": 1, + "height": 144, + "quality": 0.0, + "has_drm": false, + "tbr": 76.667, + "url": "https://rr2---sn-5hne6nzk.googlevideo.com/videoplayback?expire=1694818322&ei=sosEZfXrN8mrx_APirihiAo&ip=45.93.75.130&id=o-AK8fF61bmcIHhl_2kv1XxpCtdRixUPDqG0y6aunrwcZa&itag=17&source=youtube&requiressl=yes&mh=6Q&mm=31%2C29&mn=sn-5hne6nzk%2Csn-5hnednss&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=1868750&spc=UWF9f0JgCQlRLpY93JZnveUdoMCdkmY&vprv=1&svpuc=1&mime=video%2F3gpp&gir=yes&clen=87229&dur=9.102&lmt=1660945743115413&mt=1694796392&fvip=5&fexp=24007246&beids=24350017&c=ANDROID&txp=4432434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIhAJm0eNev3CzRpx8At9YD6D6U_uxOlEflLMDjzqieM592AiBOg8lE3Gll9YjGcna1uGS30ErrF0kiBbANCYv3pAFxVg%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAKy1C4o9YUyi7o2_03UfJ8n8vXWgF4t8zB-4FXiAtJ5uAiEAh2chtgFo6quycJIs1kagkaa_AAQbEFrnFU1xEUDEqp4%3D", + "width": 176, + "language": "es", + "language_preference": -1, + "preference": -2, + "ext": "3gp", + "vcodec": "mp4v.20.3", + "acodec": "mp4a.40.2", + "dynamic_range": "SDR", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "176x144", + "aspect_ratio": 1.22, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.93 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "3gp", + "audio_ext": "none", + "vbr": null, + "abr": null, + "format": "17 - 176x144 (144p)" + }, + { + "asr": null, + "filesize": 46882, + "format_id": "394", + "format_note": "144p", + "source_preference": -1, + "fps": 30, + "audio_channels": null, + "height": 144, + "quality": 0.0, + "has_drm": false, + "tbr": 41.635, + "url": "https://rr2---sn-5hne6nzk.googlevideo.com/videoplayback?expire=1694818322&ei=sosEZfXrN8mrx_APirihiAo&ip=45.93.75.130&id=o-AK8fF61bmcIHhl_2kv1XxpCtdRixUPDqG0y6aunrwcZa&itag=394&source=youtube&requiressl=yes&mh=6Q&mm=31%2C29&mn=sn-5hne6nzk%2Csn-5hnednss&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=1868750&spc=UWF9f0JgCQlRLpY93JZnveUdoMCdkmY&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=46882&dur=9.008&lmt=1660945830044574&mt=1694796392&fvip=5&keepalive=yes&fexp=24007246&beids=24350017&c=ANDROID&txp=4432434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIhAPy1RiLiIziQCwBCdl9LzQs-qgBhFPr8kkunFNT_AAv_AiBO7wJUpUtjJNqmJPPdDvqzvyFyOLCZ8fypGl2mH5hHTQ%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAKy1C4o9YUyi7o2_03UfJ8n8vXWgF4t8zB-4FXiAtJ5uAiEAh2chtgFo6quycJIs1kagkaa_AAQbEFrnFU1xEUDEqp4%3D", + "width": 192, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "av01.0.00M.08", + "acodec": "none", + "dynamic_range": "SDR", + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "192x144", + "aspect_ratio": 1.33, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.93 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 41.635, + "format": "394 - 192x144 (144p)" + }, + { + "format_id": "269", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694818322/ei/sosEZcmcMdGVgQeatIDABA/ip/45.93.75.130/id/aba128441bdd54f4/itag/269/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/sgovp/clen%3D20615%3Bdur%3D9.008%3Bgir%3Dyes%3Bitag%3D160%3Blmt%3D1660945709376822/hls_chunk_host/rr2---sn-5hne6nzk.googlevideo.com/mh/6Q/mm/31,29/mn/sn-5hne6nzk,sn-5hnednss/ms/au,rdu/mv/m/mvi/2/pl/22/initcwndbps/1868750/vprv/1/playlist_type/DVR/dover/13/txp/4432434/mt/1694796392/fvip/5/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,sgovp,vprv,playlist_type/sig/AOq0QJ8wRQIgQgcXtjoYfO5P2A_V247zBsOdlBLyV5PjgBCu9HYqoaACIQDpBxxU-ltTF1ikH36r8t4zLQzS8-ijGUN6qgkrsmxepw%3D%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRAIgUwqIEq6_6n7j9n8Pg5Uyqz4HGjW25JDtibdnDjkVf5ICIAd24x0_6OHE1roEurSd_JHpolhfkwSPSfFj4hl3K4Er/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694818322/ei/sosEZcmcMdGVgQeatIDABA/ip/45.93.75.130/id/aba128441bdd54f4/source/youtube/requiressl/yes/playback_host/rr2---sn-5hne6nzk.googlevideo.com/mh/6Q/mm/31%2C29/mn/sn-5hne6nzk%2Csn-5hnednss/ms/au%2Crdu/mv/m/mvi/2/pl/22/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1868750/vprv/1/go/1/mt/1694796392/fvip/5/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRgIhAPjgJ5L6c_sfFyR2FFR1BaUJo8RuGjhA9_1wOND8AO3GAiEA41COxaqltPNEco0cXZsJoBy8GMUqP95BLUdTfJRNRJE%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRgIhANAG8hyS_myLc16a28Hj3fv5g_3IRWYVGBTZmhXL6wniAiEAvOFf1xoAySEDlUMNJ9ir0-sz4nZ6niJSZVtxlw78e5o%3D/file/index.m3u8", + "tbr": 76.881, + "ext": "mp4", + "fps": 30.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 0, + "has_drm": false, + "width": 192, + "height": 144, + "vcodec": "avc1.4D400C", + "acodec": "none", + "dynamic_range": "SDR", + "source_preference": -1, + "resolution": "192x144", + "aspect_ratio": 1.33, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.93 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 76.881, + "format": "269 - 192x144" + }, + { + "asr": null, + "filesize": 20615, + "format_id": "160", + "format_note": "144p", + "source_preference": -1, + "fps": 30, + "audio_channels": null, + "height": 144, + "quality": 0.0, + "has_drm": false, + "tbr": 18.308, + "url": "https://rr2---sn-5hne6nzk.googlevideo.com/videoplayback?expire=1694818322&ei=sosEZcmcMdGVgQeatIDABA&ip=45.93.75.130&id=o-ANZGIl8-Lo8u8x_fU-l5VosaHna8zx8_6Ab0CCT-vzjQ&itag=160&source=youtube&requiressl=yes&mh=6Q&mm=31%2C29&mn=sn-5hne6nzk%2Csn-5hnednss&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=1868750&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=20615&dur=9.008&lmt=1660945709376822&mt=1694796392&fvip=5&keepalive=yes&fexp=24007246&c=IOS&txp=4432434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIgDt-H-tbSs2WhIKvGQQhW9VEafRKkwQWS0W3NM6utadsCIQDUenqVpa4s1i8Bn_BGyTTfmpkRN5z8GgAF3Nw-1v4ewQ%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIhAN9Und25H4_kUjcAoZ_LVv0lAVTnPDkI-t5f7JJBA_jhAiAsXrF-84K_iBGiTwIwXS_eOlp5JPXxLEhyDj_cB8zdxQ%3D%3D", + "width": 192, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "avc1.4D400C", + "acodec": "none", + "dynamic_range": "SDR", + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "192x144", + "aspect_ratio": 1.33, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.93 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 18.308, + "format": "160 - 192x144 (144p)" + }, + { + "format_id": "603", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694818322/ei/sosEZcmcMdGVgQeatIDABA/ip/45.93.75.130/id/aba128441bdd54f4/itag/603/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D62495%3Bdur%3D9.009%3Bgir%3Dyes%3Bitag%3D278%3Blmt%3D1660945834968282/hls_chunk_host/rr2---sn-5hne6nzk.googlevideo.com/mh/6Q/mm/31,29/mn/sn-5hne6nzk,sn-5hnednss/ms/au,rdu/mv/m/mvi/2/pl/22/initcwndbps/1868750/vprv/1/playlist_type/DVR/dover/13/txp/4437434/mt/1694796392/fvip/5/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRQIgBGuI3nookacol01lxttwW1wQly7NyU3xVdF5XvrXw78CIQCSdYDE7zYLc8ayT2mBudyZhVKmPldjtuAvQlXmnNQifw%3D%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRAIgPXv-ptK9054Zrx8-AQvkhoU492LfrTDwnHuCwTUtniUCIDcFqkhXTInDcoHu9BmyiQCqqWI9cYdkPMgapL4fjLEz/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694818322/ei/sosEZcmcMdGVgQeatIDABA/ip/45.93.75.130/id/aba128441bdd54f4/source/youtube/requiressl/yes/playback_host/rr2---sn-5hne6nzk.googlevideo.com/mh/6Q/mm/31%2C29/mn/sn-5hne6nzk%2Csn-5hnednss/ms/au%2Crdu/mv/m/mvi/2/pl/22/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1868750/vprv/1/go/1/mt/1694796392/fvip/5/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRgIhAPjgJ5L6c_sfFyR2FFR1BaUJo8RuGjhA9_1wOND8AO3GAiEA41COxaqltPNEco0cXZsJoBy8GMUqP95BLUdTfJRNRJE%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRgIhANAG8hyS_myLc16a28Hj3fv5g_3IRWYVGBTZmhXL6wniAiEAvOFf1xoAySEDlUMNJ9ir0-sz4nZ6niJSZVtxlw78e5o%3D/file/index.m3u8", + "tbr": 121.361, + "ext": "mp4", + "fps": 30.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 0, + "has_drm": false, + "width": 192, + "height": 144, + "vcodec": "vp09.00.11.08", + "acodec": "none", + "dynamic_range": "SDR", + "source_preference": -1, + "resolution": "192x144", + "aspect_ratio": 1.33, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.93 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 121.361, + "format": "603 - 192x144" + }, + { + "asr": null, + "filesize": 62495, + "format_id": "278", + "format_note": "144p", + "source_preference": -1, + "fps": 30, + "audio_channels": null, + "height": 144, + "quality": 0.0, + "has_drm": false, + "tbr": 55.495, + "url": "https://rr2---sn-5hne6nzk.googlevideo.com/videoplayback?expire=1694818322&ei=sosEZcmcMdGVgQeatIDABA&ip=45.93.75.130&id=o-ANZGIl8-Lo8u8x_fU-l5VosaHna8zx8_6Ab0CCT-vzjQ&itag=278&source=youtube&requiressl=yes&mh=6Q&mm=31%2C29&mn=sn-5hne6nzk%2Csn-5hnednss&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=1868750&vprv=1&svpuc=1&mime=video%2Fwebm&gir=yes&clen=62495&dur=9.009&lmt=1660945834968282&mt=1694796392&fvip=5&keepalive=yes&fexp=24007246&c=IOS&txp=4437434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIgdnBmkEjoPBnSfzzXd25s8qgngC7eX21qw3VMNKAwgmACIQC2vyhWg_QF6ygMsvTVjZr4u6Ij53pKW5r0heS3yNn1KA%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIhAN9Und25H4_kUjcAoZ_LVv0lAVTnPDkI-t5f7JJBA_jhAiAsXrF-84K_iBGiTwIwXS_eOlp5JPXxLEhyDj_cB8zdxQ%3D%3D", + "width": 192, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "vp09.00.11.08", + "acodec": "none", + "dynamic_range": "SDR", + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "192x144", + "aspect_ratio": 1.33, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.93 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "webm", + "audio_ext": "none", + "abr": 0, + "vbr": 55.495, + "format": "278 - 192x144 (144p)" + }, + { + "asr": null, + "filesize": 43466, + "format_id": "395", + "format_note": "240p", + "source_preference": -1, + "fps": 30, + "audio_channels": null, + "height": 240, + "quality": 5.0, + "has_drm": false, + "tbr": 38.602, + "url": "https://rr2---sn-5hne6nzk.googlevideo.com/videoplayback?expire=1694818322&ei=sosEZfXrN8mrx_APirihiAo&ip=45.93.75.130&id=o-AK8fF61bmcIHhl_2kv1XxpCtdRixUPDqG0y6aunrwcZa&itag=395&source=youtube&requiressl=yes&mh=6Q&mm=31%2C29&mn=sn-5hne6nzk%2Csn-5hnednss&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=1868750&spc=UWF9f0JgCQlRLpY93JZnveUdoMCdkmY&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=43466&dur=9.008&lmt=1660945757515296&mt=1694796392&fvip=5&keepalive=yes&fexp=24007246&beids=24350017&c=ANDROID&txp=4432434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIhAKAznEV8u9gdGIw-xnQZ5fPmsGnkzOfDllv9dCmxThj3AiAX9AU4BXvDNDkqaKb8fZc2fW3CVqtyAFs2m9VWFJnpGg%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAKy1C4o9YUyi7o2_03UfJ8n8vXWgF4t8zB-4FXiAtJ5uAiEAh2chtgFo6quycJIs1kagkaa_AAQbEFrnFU1xEUDEqp4%3D", + "width": 320, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "av01.0.00M.08", + "acodec": "none", + "dynamic_range": "SDR", + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "320x240", + "aspect_ratio": 1.33, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.93 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 38.602, + "format": "395 - 320x240 (240p)" + }, + { + "format_id": "229", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694818322/ei/sosEZcmcMdGVgQeatIDABA/ip/45.93.75.130/id/aba128441bdd54f4/itag/229/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/sgovp/clen%3D33314%3Bdur%3D9.008%3Bgir%3Dyes%3Bitag%3D133%3Blmt%3D1660945693258184/hls_chunk_host/rr2---sn-5hne6nzk.googlevideo.com/mh/6Q/mm/31,29/mn/sn-5hne6nzk,sn-5hnednss/ms/au,rdu/mv/m/mvi/2/pl/22/initcwndbps/1868750/vprv/1/playlist_type/DVR/dover/13/txp/4432434/mt/1694796392/fvip/5/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,sgovp,vprv,playlist_type/sig/AOq0QJ8wRAIga9TRNQyPULGWzOadayPTTJ2uaAzDpvEgsj8n3r4yc3MCIDI9doRyVEkW2Gl_zdnGZcuJ062cBUwSbMhSAFJOPNO-/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRAIgUxcm641l-XXMfwfSIDn7LRN6TL-1E5Kn9AZb_3w1QJgCIFnUrvyEQgumKwuFpFLgJc6tYLBq2KpuKqsPT_R-OYQz/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694818322/ei/sosEZcmcMdGVgQeatIDABA/ip/45.93.75.130/id/aba128441bdd54f4/source/youtube/requiressl/yes/playback_host/rr2---sn-5hne6nzk.googlevideo.com/mh/6Q/mm/31%2C29/mn/sn-5hne6nzk%2Csn-5hnednss/ms/au%2Crdu/mv/m/mvi/2/pl/22/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1868750/vprv/1/go/1/mt/1694796392/fvip/5/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRgIhAPjgJ5L6c_sfFyR2FFR1BaUJo8RuGjhA9_1wOND8AO3GAiEA41COxaqltPNEco0cXZsJoBy8GMUqP95BLUdTfJRNRJE%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRgIhANAG8hyS_myLc16a28Hj3fv5g_3IRWYVGBTZmhXL6wniAiEAvOFf1xoAySEDlUMNJ9ir0-sz4nZ6niJSZVtxlw78e5o%3D/file/index.m3u8", + "tbr": 93.712, + "ext": "mp4", + "fps": 30.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 5, + "has_drm": false, + "width": 320, + "height": 240, + "vcodec": "avc1.4D400D", + "acodec": "none", + "dynamic_range": "SDR", + "source_preference": -1, + "resolution": "320x240", + "aspect_ratio": 1.33, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.93 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 93.712, + "format": "229 - 320x240" + }, + { + "asr": null, + "filesize": 33314, + "format_id": "133", + "format_note": "240p", + "source_preference": -1, + "fps": 30, + "audio_channels": null, + "height": 240, + "quality": 5.0, + "has_drm": false, + "tbr": 29.586, + "url": "https://rr2---sn-5hne6nzk.googlevideo.com/videoplayback?expire=1694818322&ei=sosEZcmcMdGVgQeatIDABA&ip=45.93.75.130&id=o-ANZGIl8-Lo8u8x_fU-l5VosaHna8zx8_6Ab0CCT-vzjQ&itag=133&source=youtube&requiressl=yes&mh=6Q&mm=31%2C29&mn=sn-5hne6nzk%2Csn-5hnednss&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=1868750&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=33314&dur=9.008&lmt=1660945693258184&mt=1694796392&fvip=5&keepalive=yes&fexp=24007246&c=IOS&txp=4432434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIgZ162f_shiHFiTqDbFlCcYIMSm50IqmFJskU_ilfkR7ICIQD0Yg9HTRFcLVWqlVFLH5KfcmcJIAPPL_JwTGz8-sUeRg%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIhAN9Und25H4_kUjcAoZ_LVv0lAVTnPDkI-t5f7JJBA_jhAiAsXrF-84K_iBGiTwIwXS_eOlp5JPXxLEhyDj_cB8zdxQ%3D%3D", + "width": 320, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "avc1.4D400D", + "acodec": "none", + "dynamic_range": "SDR", + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "320x240", + "aspect_ratio": 1.33, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.93 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 29.586, + "format": "133 - 320x240 (240p)" + }, + { + "format_id": "604", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694818322/ei/sosEZcmcMdGVgQeatIDABA/ip/45.93.75.130/id/aba128441bdd54f4/itag/604/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D59447%3Bdur%3D9.009%3Bgir%3Dyes%3Bitag%3D242%3Blmt%3D1660945831908022/hls_chunk_host/rr2---sn-5hne6nzk.googlevideo.com/mh/6Q/mm/31,29/mn/sn-5hne6nzk,sn-5hnednss/ms/au,rdu/mv/m/mvi/2/pl/22/initcwndbps/1868750/vprv/1/playlist_type/DVR/dover/13/txp/4437434/mt/1694796392/fvip/5/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRAIgGUWXjMnMwp3LrRwpFG_Amn59n4qRRqrZkPJsMICaxVwCIAHh2FLJkhxWFJmwuNUgxdj4ladXOsFvA0VkznJfb9qw/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRAIgCEFL2MHdLtR9MnhJG4Ya9L94F7amV-RdcNKLf5pYPJgCIAu51bEfddZb1FmP0gceNYOaLo7sX4yWMTaVnMMQFGyV/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694818322/ei/sosEZcmcMdGVgQeatIDABA/ip/45.93.75.130/id/aba128441bdd54f4/source/youtube/requiressl/yes/playback_host/rr2---sn-5hne6nzk.googlevideo.com/mh/6Q/mm/31%2C29/mn/sn-5hne6nzk%2Csn-5hnednss/ms/au%2Crdu/mv/m/mvi/2/pl/22/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1868750/vprv/1/go/1/mt/1694796392/fvip/5/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRgIhAPjgJ5L6c_sfFyR2FFR1BaUJo8RuGjhA9_1wOND8AO3GAiEA41COxaqltPNEco0cXZsJoBy8GMUqP95BLUdTfJRNRJE%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRgIhANAG8hyS_myLc16a28Hj3fv5g_3IRWYVGBTZmhXL6wniAiEAvOFf1xoAySEDlUMNJ9ir0-sz4nZ6niJSZVtxlw78e5o%3D/file/index.m3u8", + "tbr": 137.419, + "ext": "mp4", + "fps": 30.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 5, + "has_drm": false, + "width": 320, + "height": 240, + "vcodec": "vp09.00.20.08", + "acodec": "none", + "dynamic_range": "SDR", + "source_preference": -1, + "resolution": "320x240", + "aspect_ratio": 1.33, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.93 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 137.419, + "format": "604 - 320x240" + }, + { + "asr": null, + "filesize": 59447, + "format_id": "242", + "format_note": "240p", + "source_preference": -1, + "fps": 30, + "audio_channels": null, + "height": 240, + "quality": 5.0, + "has_drm": false, + "tbr": 52.788, + "url": "https://rr2---sn-5hne6nzk.googlevideo.com/videoplayback?expire=1694818322&ei=sosEZcmcMdGVgQeatIDABA&ip=45.93.75.130&id=o-ANZGIl8-Lo8u8x_fU-l5VosaHna8zx8_6Ab0CCT-vzjQ&itag=242&source=youtube&requiressl=yes&mh=6Q&mm=31%2C29&mn=sn-5hne6nzk%2Csn-5hnednss&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=1868750&vprv=1&svpuc=1&mime=video%2Fwebm&gir=yes&clen=59447&dur=9.009&lmt=1660945831908022&mt=1694796392&fvip=5&keepalive=yes&fexp=24007246&c=IOS&txp=4437434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIgKoX6qbjGA7NZBetcQjUknVjlYrY6fs7SQx19Ipo2ryECIQCf3lw-QBwAesWx-vSENGxsl7MKlnjSTsdfr0m6M24wQA%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIhAN9Und25H4_kUjcAoZ_LVv0lAVTnPDkI-t5f7JJBA_jhAiAsXrF-84K_iBGiTwIwXS_eOlp5JPXxLEhyDj_cB8zdxQ%3D%3D", + "width": 320, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "vp09.00.20.08", + "acodec": "none", + "dynamic_range": "SDR", + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "320x240", + "aspect_ratio": 1.33, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.93 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "webm", + "audio_ext": "none", + "abr": 0, + "vbr": 52.788, + "format": "242 - 320x240 (240p)" + }, + { + "asr": null, + "filesize": 78099, + "format_id": "396", + "format_note": "360p", + "source_preference": -1, + "fps": 30, + "audio_channels": null, + "height": 360, + "quality": 6.0, + "has_drm": false, + "tbr": 69.359, + "url": "https://rr2---sn-5hne6nzk.googlevideo.com/videoplayback?expire=1694818322&ei=sosEZfXrN8mrx_APirihiAo&ip=45.93.75.130&id=o-AK8fF61bmcIHhl_2kv1XxpCtdRixUPDqG0y6aunrwcZa&itag=396&source=youtube&requiressl=yes&mh=6Q&mm=31%2C29&mn=sn-5hne6nzk%2Csn-5hnednss&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=1868750&spc=UWF9f0JgCQlRLpY93JZnveUdoMCdkmY&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=78099&dur=9.008&lmt=1660946011095705&mt=1694796392&fvip=5&keepalive=yes&fexp=24007246&beids=24350017&c=ANDROID&txp=4432434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIhALqMylxURjz4Af-zjLqhcEiN-TtHmLesXrW1-VKUpBKbAiAQ2snXUz8_MBZV_El2swlMq96USDuG-Pc6562ddyrruw%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAKy1C4o9YUyi7o2_03UfJ8n8vXWgF4t8zB-4FXiAtJ5uAiEAh2chtgFo6quycJIs1kagkaa_AAQbEFrnFU1xEUDEqp4%3D", + "width": 480, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "av01.0.01M.08", + "acodec": "none", + "dynamic_range": "SDR", + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "480x360", + "aspect_ratio": 1.33, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.93 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 69.359, + "format": "396 - 480x360 (360p)" + }, + { + "format_id": "230", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694818322/ei/sosEZcmcMdGVgQeatIDABA/ip/45.93.75.130/id/aba128441bdd54f4/itag/230/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/sgovp/clen%3D57150%3Bdur%3D9.008%3Bgir%3Dyes%3Bitag%3D134%3Blmt%3D1660945693864671/hls_chunk_host/rr2---sn-5hne6nzk.googlevideo.com/mh/6Q/mm/31,29/mn/sn-5hne6nzk,sn-5hnednss/ms/au,rdu/mv/m/mvi/2/pl/22/initcwndbps/1868750/vprv/1/playlist_type/DVR/dover/13/txp/4432434/mt/1694796392/fvip/5/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,sgovp,vprv,playlist_type/sig/AOq0QJ8wRgIhAPlimmuPQAqCjvImM628y5Sev-s8IFlmIgHgvnyQ0urvAiEAjoo90EgG2S6aTxGczIIpc62o3EuyLjj0wCISvVpD24c%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRAIgQRUECkdnB3631r6qXINyt7T85eoNkODlEAs3-inoQmUCICJHYqCoDq4V8bL0_BOgh29FdfFzpOZFCLNOJl3askgj/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694818322/ei/sosEZcmcMdGVgQeatIDABA/ip/45.93.75.130/id/aba128441bdd54f4/source/youtube/requiressl/yes/playback_host/rr2---sn-5hne6nzk.googlevideo.com/mh/6Q/mm/31%2C29/mn/sn-5hne6nzk%2Csn-5hnednss/ms/au%2Crdu/mv/m/mvi/2/pl/22/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1868750/vprv/1/go/1/mt/1694796392/fvip/5/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRgIhAPjgJ5L6c_sfFyR2FFR1BaUJo8RuGjhA9_1wOND8AO3GAiEA41COxaqltPNEco0cXZsJoBy8GMUqP95BLUdTfJRNRJE%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRgIhANAG8hyS_myLc16a28Hj3fv5g_3IRWYVGBTZmhXL6wniAiEAvOFf1xoAySEDlUMNJ9ir0-sz4nZ6niJSZVtxlw78e5o%3D/file/index.m3u8", + "tbr": 204.883, + "ext": "mp4", + "fps": 30.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 6, + "has_drm": false, + "width": 480, + "height": 360, + "vcodec": "avc1.4D401E", + "acodec": "none", + "dynamic_range": "SDR", + "source_preference": -1, + "resolution": "480x360", + "aspect_ratio": 1.33, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.93 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 204.883, + "format": "230 - 480x360" + }, + { + "asr": null, + "filesize": 57150, + "format_id": "134", + "format_note": "360p", + "source_preference": -1, + "fps": 30, + "audio_channels": null, + "height": 360, + "quality": 6.0, + "has_drm": false, + "tbr": 50.754, + "url": "https://rr2---sn-5hne6nzk.googlevideo.com/videoplayback?expire=1694818322&ei=sosEZcmcMdGVgQeatIDABA&ip=45.93.75.130&id=o-ANZGIl8-Lo8u8x_fU-l5VosaHna8zx8_6Ab0CCT-vzjQ&itag=134&source=youtube&requiressl=yes&mh=6Q&mm=31%2C29&mn=sn-5hne6nzk%2Csn-5hnednss&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=1868750&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=57150&dur=9.008&lmt=1660945693864671&mt=1694796392&fvip=5&keepalive=yes&fexp=24007246&c=IOS&txp=4432434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhAJA0OjKaooY1Q5QF7gE_cYk1KuZVIgQvuqZY3kWE1NJAAiEA-0Sws-x-LUUuhgRWR9BkpRHs28zAQ2c3UUTmvvHEWgs%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIhAN9Und25H4_kUjcAoZ_LVv0lAVTnPDkI-t5f7JJBA_jhAiAsXrF-84K_iBGiTwIwXS_eOlp5JPXxLEhyDj_cB8zdxQ%3D%3D", + "width": 480, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "avc1.4D401E", + "acodec": "none", + "dynamic_range": "SDR", + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "480x360", + "aspect_ratio": 1.33, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.93 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 50.754, + "format": "134 - 480x360 (360p)" + }, + { + "asr": 44100, + "filesize": null, + "format_id": "18", + "format_note": "360p", + "source_preference": -1, + "fps": 30, + "audio_channels": 2, + "height": 360, + "quality": 6.0, + "has_drm": false, + "tbr": 180.939, + "url": "https://rr2---sn-5hne6nzk.googlevideo.com/videoplayback?expire=1694818322&ei=sosEZfXrN8mrx_APirihiAo&ip=45.93.75.130&id=o-AK8fF61bmcIHhl_2kv1XxpCtdRixUPDqG0y6aunrwcZa&itag=18&source=youtube&requiressl=yes&mh=6Q&mm=31%2C29&mn=sn-5hne6nzk%2Csn-5hnednss&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=1868750&spc=UWF9f0JgCQlRLpY93JZnveUdoMCdkmY&vprv=1&svpuc=1&mime=video%2Fmp4&cnr=14&ratebypass=yes&dur=9.055&lmt=1665508348849369&mt=1694796392&fvip=5&fexp=24007246&beids=24350017&c=ANDROID&txp=4438434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Ccnr%2Cratebypass%2Cdur%2Clmt&sig=AOq0QJ8wRQIhALn143d2vS16xd_ndXj_rB8QOeHSCHC9YxSeOaRMF9eWAiAaYxqrRyV5bREBHLPCrs8Wk8Msm3hJrj11OJc2RIEyzw%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAKy1C4o9YUyi7o2_03UfJ8n8vXWgF4t8zB-4FXiAtJ5uAiEAh2chtgFo6quycJIs1kagkaa_AAQbEFrnFU1xEUDEqp4%3D", + "width": 480, + "language": "es", + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "avc1.42001E", + "acodec": "mp4a.40.2", + "dynamic_range": "SDR", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "480x360", + "aspect_ratio": 1.33, + "filesize_approx": 208441, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.93 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "vbr": null, + "abr": null, + "format": "18 - 480x360 (360p)" + }, + { + "format_id": "605", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694818322/ei/sosEZcmcMdGVgQeatIDABA/ip/45.93.75.130/id/aba128441bdd54f4/itag/605/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D104373%3Bdur%3D9.009%3Bgir%3Dyes%3Bitag%3D243%3Blmt%3D1660945832037331/hls_chunk_host/rr2---sn-5hne6nzk.googlevideo.com/mh/6Q/mm/31,29/mn/sn-5hne6nzk,sn-5hnednss/ms/au,rdu/mv/m/mvi/2/pl/22/initcwndbps/1868750/vprv/1/playlist_type/DVR/dover/13/txp/4437434/mt/1694796392/fvip/5/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRQIgDUcdPIHNGJ4aEjIJdBVww0ROsh1PbMeCJwTE0CgnimUCIQDFx4dtEEiTS_m2NlDSSPD-kxF0RyhVQPRE2E5E0LSg0g%3D%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRgIhALtfpQjfbQMYbxHz0bqM35Iu7blF-YOjrL5X58URVXkNAiEAh4_Ps_0f8rK_EHkHK0BpnrNLNrjBxIUYJafgM3kn1XI%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694818322/ei/sosEZcmcMdGVgQeatIDABA/ip/45.93.75.130/id/aba128441bdd54f4/source/youtube/requiressl/yes/playback_host/rr2---sn-5hne6nzk.googlevideo.com/mh/6Q/mm/31%2C29/mn/sn-5hne6nzk%2Csn-5hnednss/ms/au%2Crdu/mv/m/mvi/2/pl/22/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1868750/vprv/1/go/1/mt/1694796392/fvip/5/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRgIhAPjgJ5L6c_sfFyR2FFR1BaUJo8RuGjhA9_1wOND8AO3GAiEA41COxaqltPNEco0cXZsJoBy8GMUqP95BLUdTfJRNRJE%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRgIhANAG8hyS_myLc16a28Hj3fv5g_3IRWYVGBTZmhXL6wniAiEAvOFf1xoAySEDlUMNJ9ir0-sz4nZ6niJSZVtxlw78e5o%3D/file/index.m3u8", + "tbr": 265.96, + "ext": "mp4", + "fps": 30.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 6, + "has_drm": false, + "width": 480, + "height": 360, + "vcodec": "vp09.00.21.08", + "acodec": "none", + "dynamic_range": "SDR", + "source_preference": -1, + "resolution": "480x360", + "aspect_ratio": 1.33, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.93 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 265.96, + "format": "605 - 480x360" + }, + { + "asr": null, + "filesize": 104373, + "format_id": "243", + "format_note": "360p", + "source_preference": -1, + "fps": 30, + "audio_channels": null, + "height": 360, + "quality": 6.0, + "has_drm": false, + "tbr": 92.683, + "url": "https://rr2---sn-5hne6nzk.googlevideo.com/videoplayback?expire=1694818322&ei=sosEZcmcMdGVgQeatIDABA&ip=45.93.75.130&id=o-ANZGIl8-Lo8u8x_fU-l5VosaHna8zx8_6Ab0CCT-vzjQ&itag=243&source=youtube&requiressl=yes&mh=6Q&mm=31%2C29&mn=sn-5hne6nzk%2Csn-5hnednss&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=1868750&vprv=1&svpuc=1&mime=video%2Fwebm&gir=yes&clen=104373&dur=9.009&lmt=1660945832037331&mt=1694796392&fvip=5&keepalive=yes&fexp=24007246&c=IOS&txp=4437434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhAMLnlCaLvJ2scyVr6qYrCp3rzn_Op9eerIVWyp62NXKIAiEAnswRfxH5KssHQAKETF2MPncVWX_eDgpTXBEHN589-Xo%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIhAN9Und25H4_kUjcAoZ_LVv0lAVTnPDkI-t5f7JJBA_jhAiAsXrF-84K_iBGiTwIwXS_eOlp5JPXxLEhyDj_cB8zdxQ%3D%3D", + "width": 480, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "vp09.00.21.08", + "acodec": "none", + "dynamic_range": "SDR", + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "480x360", + "aspect_ratio": 1.33, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.93 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "webm", + "audio_ext": "none", + "abr": 0, + "vbr": 92.683, + "format": "243 - 480x360 (360p)" + } + ], + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/3.jpg", + "preference": -37, + "id": "0" + }, + { + "url": "https://i.ytimg.com/vi_webp/q6EoRBvdVPQ/3.webp", + "preference": -36, + "id": "1" + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/2.jpg", + "preference": -35, + "id": "2" + }, + { + "url": "https://i.ytimg.com/vi_webp/q6EoRBvdVPQ/2.webp", + "preference": -34, + "id": "3" + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/1.jpg", + "preference": -33, + "id": "4" + }, + { + "url": "https://i.ytimg.com/vi_webp/q6EoRBvdVPQ/1.webp", + "preference": -32, + "id": "5" + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/mq3.jpg", + "preference": -31, + "id": "6" + }, + { + "url": "https://i.ytimg.com/vi_webp/q6EoRBvdVPQ/mq3.webp", + "preference": -30, + "id": "7" + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/mq2.jpg", + "preference": -29, + "id": "8" + }, + { + "url": "https://i.ytimg.com/vi_webp/q6EoRBvdVPQ/mq2.webp", + "preference": -28, + "id": "9" + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/mq1.jpg", + "preference": -27, + "id": "10" + }, + { + "url": "https://i.ytimg.com/vi_webp/q6EoRBvdVPQ/mq1.webp", + "preference": -26, + "id": "11" + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/hq3.jpg", + "preference": -25, + "id": "12" + }, + { + "url": "https://i.ytimg.com/vi_webp/q6EoRBvdVPQ/hq3.webp", + "preference": -24, + "id": "13" + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/hq2.jpg", + "preference": -23, + "id": "14" + }, + { + "url": "https://i.ytimg.com/vi_webp/q6EoRBvdVPQ/hq2.webp", + "preference": -22, + "id": "15" + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/hq1.jpg", + "preference": -21, + "id": "16" + }, + { + "url": "https://i.ytimg.com/vi_webp/q6EoRBvdVPQ/hq1.webp", + "preference": -20, + "id": "17" + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/sd3.jpg", + "preference": -19, + "id": "18" + }, + { + "url": "https://i.ytimg.com/vi_webp/q6EoRBvdVPQ/sd3.webp", + "preference": -18, + "id": "19" + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/sd2.jpg", + "preference": -17, + "id": "20" + }, + { + "url": "https://i.ytimg.com/vi_webp/q6EoRBvdVPQ/sd2.webp", + "preference": -16, + "id": "21" + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/sd1.jpg", + "preference": -15, + "id": "22" + }, + { + "url": "https://i.ytimg.com/vi_webp/q6EoRBvdVPQ/sd1.webp", + "preference": -14, + "id": "23" + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/default.jpg", + "preference": -13, + "id": "24" + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/default.jpg?sqp=-oaymwEkCHgQWvKriqkDGvABAfgB3gOAAugCigIMCAAQARg8IGUoPzAP&rs=AOn4CLA2OGwTh8r-rrPHmMqaeS0RNRrFaQ", + "height": 90, + "width": 120, + "preference": -13, + "id": "25", + "resolution": "120x90" + }, + { + "url": "https://i.ytimg.com/vi_webp/q6EoRBvdVPQ/default.webp", + "preference": -12, + "id": "26" + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/mqdefault.jpg", + "preference": -11, + "id": "27" + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/mqdefault.jpg?sqp=-oaymwEmCMACELQB8quKqQMa8AEB-AHeA4AC6AKKAgwIABABGDwgZSg_MA8=&rs=AOn4CLAzOKoJNw88BUkuLNLP9QDOp1ZYpQ", + "height": 180, + "width": 320, + "preference": -11, + "id": "28", + "resolution": "320x180" + }, + { + "url": "https://i.ytimg.com/vi_webp/q6EoRBvdVPQ/mqdefault.webp", + "preference": -10, + "id": "29" + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/0.jpg", + "preference": -9, + "id": "30" + }, + { + "url": "https://i.ytimg.com/vi_webp/q6EoRBvdVPQ/0.webp", + "preference": -8, + "id": "31" + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/hqdefault.jpg", + "preference": -7, + "id": "32" + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/hqdefault.jpg?sqp=-oaymwE1CKgBEF5IVfKriqkDKAgBFQAAiEIYAXABwAEG8AEB-AHeA4AC6AKKAgwIABABGDwgZSg_MA8=&rs=AOn4CLAJYg16HMBdEsv9lYBJyNqA5G3anQ", + "height": 94, + "width": 168, + "preference": -7, + "id": "33", + "resolution": "168x94" + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/hqdefault.jpg?sqp=-oaymwE1CMQBEG5IVfKriqkDKAgBFQAAiEIYAXABwAEG8AEB-AHeA4AC6AKKAgwIABABGDwgZSg_MA8=&rs=AOn4CLAgCNP9UuQas-D59hHHM-RqkUvA6g", + "height": 110, + "width": 196, + "preference": -7, + "id": "34", + "resolution": "196x110" + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/hqdefault.jpg?sqp=-oaymwE2CPYBEIoBSFXyq4qpAygIARUAAIhCGAFwAcABBvABAfgB3gOAAugCigIMCAAQARg8IGUoPzAP&rs=AOn4CLCTWaY5897XxhcpRyVtGQQNuMHfTg", + "height": 138, + "width": 246, + "preference": -7, + "id": "35", + "resolution": "246x138" + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/hqdefault.jpg?sqp=-oaymwE2CNACELwBSFXyq4qpAygIARUAAIhCGAFwAcABBvABAfgB3gOAAugCigIMCAAQARg8IGUoPzAP&rs=AOn4CLCeS6NC75yTYvyP4DsehZ3oXNuxMQ", + "height": 188, + "width": 336, + "preference": -7, + "id": "36", + "resolution": "336x188" + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/hqdefault.jpg?sqp=-oaymwEmCOADEOgC8quKqQMa8AEB-AHeA4AC6AKKAgwIABABGDwgZSg_MA8=&rs=AOn4CLDrmOZxhdEtPlKJawitTPTH-Zfe9Q", + "height": 360, + "width": 480, + "preference": -7, + "id": "37", + "resolution": "480x360" + }, + { + "url": "https://i.ytimg.com/vi_webp/q6EoRBvdVPQ/hqdefault.webp", + "preference": -6, + "id": "38" + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/sddefault.jpg", + "preference": -5, + "id": "39" + }, + { + "url": "https://i.ytimg.com/vi_webp/q6EoRBvdVPQ/sddefault.webp", + "preference": -4, + "id": "40" + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/hq720.jpg", + "preference": -3, + "id": "41" + }, + { + "url": "https://i.ytimg.com/vi_webp/q6EoRBvdVPQ/hq720.webp", + "preference": -2, + "id": "42" + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/maxresdefault.jpg", + "preference": -1, + "id": "43" + }, + { + "url": "https://i.ytimg.com/vi_webp/q6EoRBvdVPQ/maxresdefault.webp", + "preference": 0, + "id": "44" + } + ], + "thumbnail": "https://i.ytimg.com/vi/q6EoRBvdVPQ/hqdefault.jpg?sqp=-oaymwEmCOADEOgC8quKqQMa8AEB-AHeA4AC6AKKAgwIABABGDwgZSg_MA8=&rs=AOn4CLDrmOZxhdEtPlKJawitTPTH-Zfe9Q", + "description": "Dinosauri bisesti dalle voci funeste. Original title: \"I dinosauri antropomorfi hanno il sangue nel ritmo\" (literally \"The anthropomorphic dinosaurs have blood in their rhythm\"), as opposite to http://youtu.be/e6MLjaKhp5U\nIf you are that \"wtf this is so funny i want to know shit\"-type of person, here's some info/faq:\n- original cartoon: https://youtu.be/brLqiYj5lQw\n- original song: http://youtu.be/v_XJIsDJgXc?t=4m1s (at 4:01)\n- original yee: https://youtu.be/hCKQP9IHHcA\n- \"is this a remix?\" sort of. i arranged the dinos so that they would sing along with the music and that's it\n- \"why does the dino say yee?\" in the original footage he was calling the other dinosaur by name (\"Peek\")\n- \"fuck everything! i went through the whole movie and there was no yee sound! i hate my life\" dude, yee is only in the italian version of the movie, ciao ciao pizza ferrari\n- \"is the italian dub as atrocious as the english one?\" you bet\n- \"why did you make this video?\" i was trying to make a burrito out of your stupid questions and this happened\n- \"how did this become so popular?\" illuminati", + "channel_id": "UC-fD_qwTEQQ1L-MUWx_mNvg", + "channel_url": "https://www.youtube.com/channel/UC-fD_qwTEQQ1L-MUWx_mNvg", + "duration": 9, + "view_count": 96269118, + "average_rating": null, + "age_limit": 0, + "webpage_url": "https://www.youtube.com/watch?v=q6EoRBvdVPQ", + "categories": ["Comedy"], + "tags": [ + "voci", + "ambigue", + "antigue", + "antiche", + "cantiche", + "canzone", + "anthropomorphic dinosaurs", + "reddit", + "tumblr", + "dino", + "dinosaur", + "dingo pictures", + "phoenix games", + "meme", + "important videos", + "playlist" + ], + "playable_in_embed": true, + "live_status": "not_live", + "release_timestamp": null, + "_format_sort_fields": [ + "quality", + "res", + "fps", + "hdr:12", + "source", + "vcodec:vp9.2", + "channels", + "acodec", + "lang", + "proto" + ], + "automatic_captions": {}, + "subtitles": {}, + "comment_count": 76000, + "chapters": null, + "heatmap": [], + "like_count": 1501556, + "channel": "revergo", + "channel_follower_count": 64500, + "uploader": "revergo", + "uploader_id": "@revergo", + "uploader_url": "https://www.youtube.com/@revergo", + "upload_date": "20120229", + "availability": "public", + "original_url": "https://www.youtube.com/watch?v=q6EoRBvdVPQ", + "webpage_url_basename": "watch", + "webpage_url_domain": "youtube.com", + "extractor": "youtube", + "extractor_key": "Youtube", + "playlist": null, + "playlist_index": null, + "display_id": "q6EoRBvdVPQ", + "fulltitle": "Yee", + "duration_string": "9", + "is_live": false, + "was_live": false, + "requested_subtitles": null, + "_has_drm": null, + "epoch": 1694796723, + "requested_formats": [ + { + "asr": null, + "filesize": 104373, + "format_id": "243", + "format_note": "360p", + "source_preference": -1, + "fps": 30, + "audio_channels": null, + "height": 360, + "quality": 6.0, + "has_drm": false, + "tbr": 92.683, + "url": "https://rr2---sn-5hne6nzk.googlevideo.com/videoplayback?expire=1694818322&ei=sosEZcmcMdGVgQeatIDABA&ip=45.93.75.130&id=o-ANZGIl8-Lo8u8x_fU-l5VosaHna8zx8_6Ab0CCT-vzjQ&itag=243&source=youtube&requiressl=yes&mh=6Q&mm=31%2C29&mn=sn-5hne6nzk%2Csn-5hnednss&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=1868750&vprv=1&svpuc=1&mime=video%2Fwebm&gir=yes&clen=104373&dur=9.009&lmt=1660945832037331&mt=1694796392&fvip=5&keepalive=yes&fexp=24007246&c=IOS&txp=4437434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhAMLnlCaLvJ2scyVr6qYrCp3rzn_Op9eerIVWyp62NXKIAiEAnswRfxH5KssHQAKETF2MPncVWX_eDgpTXBEHN589-Xo%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIhAN9Und25H4_kUjcAoZ_LVv0lAVTnPDkI-t5f7JJBA_jhAiAsXrF-84K_iBGiTwIwXS_eOlp5JPXxLEhyDj_cB8zdxQ%3D%3D", + "width": 480, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "vp09.00.21.08", + "acodec": "none", + "dynamic_range": "SDR", + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "480x360", + "aspect_ratio": 1.33, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.93 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "webm", + "audio_ext": "none", + "abr": 0, + "vbr": 92.683, + "format": "243 - 480x360 (360p)" + }, + { + "asr": 48000, + "filesize": 123202, + "format_id": "251", + "format_note": "medium", + "source_preference": -1, + "fps": null, + "audio_channels": 2, + "height": null, + "quality": 3.0, + "has_drm": false, + "tbr": 109.257, + "url": "https://rr2---sn-5hne6nzk.googlevideo.com/videoplayback?expire=1694818322&ei=sosEZfXrN8mrx_APirihiAo&ip=45.93.75.130&id=o-AK8fF61bmcIHhl_2kv1XxpCtdRixUPDqG0y6aunrwcZa&itag=251&source=youtube&requiressl=yes&mh=6Q&mm=31%2C29&mn=sn-5hne6nzk%2Csn-5hnednss&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=1868750&spc=UWF9f0JgCQlRLpY93JZnveUdoMCdkmY&vprv=1&svpuc=1&mime=audio%2Fwebm&gir=yes&clen=123202&dur=9.021&lmt=1660945472183333&mt=1694796392&fvip=5&keepalive=yes&fexp=24007246&beids=24350017&c=ANDROID&txp=4432434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIhAMvJsObaszrBWjSOzW_wD0jXOBJTmZsU0WpEG2pSqXaHAiBxmFJUftWY3sPtSdbaSoTYHfdHxOYHupAA85TROnKjIg%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAKy1C4o9YUyi7o2_03UfJ8n8vXWgF4t8zB-4FXiAtJ5uAiEAh2chtgFo6quycJIs1kagkaa_AAQbEFrnFU1xEUDEqp4%3D", + "width": null, + "language": "es", + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "none", + "acodec": "opus", + "dynamic_range": null, + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "audio only", + "aspect_ratio": null, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.93 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "webm", + "video_ext": "none", + "vbr": 0, + "abr": 109.257, + "format": "251 - audio only (medium)" + } + ], + "format": "243 - 480x360 (360p)+251 - audio only (medium)", + "format_id": "243+251", + "ext": "webm", + "protocol": "https+https", + "language": "es", + "format_note": "360p+medium", + "filesize_approx": 227575, + "tbr": 201.94, + "width": 480, + "height": 360, + "resolution": "480x360", + "fps": 30, + "dynamic_range": "SDR", + "vcodec": "vp09.00.21.08", + "vbr": 92.683, + "stretched_ratio": null, + "aspect_ratio": 1.33, + "acodec": "opus", + "abr": 109.257, + "asr": 48000, + "audio_channels": 2 +} diff --git a/tests/components/media_extractor/snapshots/test_init.ambr b/tests/components/media_extractor/snapshots/test_init.ambr new file mode 100644 index 00000000000..d70c370b60c --- /dev/null +++ b/tests/components/media_extractor/snapshots/test_init.ambr @@ -0,0 +1,120 @@ +# serializer version: 1 +# name: test_no_target_entity + ReadOnlyDict({ + 'device_id': list([ + 'fb034c3a9fefe47c584c32a6b51817eb', + ]), + 'extra': dict({ + }), + 'media_content_id': 'https://rr2---sn-5hnekn7k.googlevideo.com/videoplayback?expire=1694805294&ei=zlgEZaLeHcrlgAeFhLrYBA&ip=45.93.75.130&id=o-AFIa6Sil61_wuEFkUVhjKkr-0pyzj2cHi52leur2vR1j&itag=22&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hnekn7k%2Csn-5hne6nzy&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=2095000&spc=UWF9f2Ob7Uhbkv1q69SZBYEqtijLGjs&vprv=1&svpuc=1&mime=video%2Fmp4&cnr=14&ratebypass=yes&dur=212.091&lmt=1694045086815467&mt=1694783390&fvip=3&fexp=24007246%2C24362685&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Ccnr%2Cratebypass%2Cdur%2Clmt&sig=AOq0QJ8wRAIgUiMmQEGPqT5Hb00S74LeTwF4PCN31mwbC_fUNSejdsQCIF2D11o2OXBxoLlOX00vyB1wfYLIo6dBnodrfYc9gH6y&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAI4QpoB0iBj-oMiNFMMdN0RN-u3nLji437a3jqTbhncSAiEAlvsdhJjG0-VZ2jCjyUZBtidBcUzYFwnk6qG7mIiNjCA%3D', + 'media_content_type': 'VIDEO', + }) +# --- +# name: test_play_media_service + ReadOnlyDict({ + 'entity_id': 'media_player.bedroom', + 'extra': dict({ + }), + 'media_content_id': 'https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694794256/ei/sC0EZYCPHbuZx_AP3bGz0Ac/ip/84.31.234.146/id/750c38c3d5a05dc4/itag/616/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D99471214%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D356%3Blmt%3D1694043438471036/hls_chunk_host/rr2---sn-5hnekn7k.googlevideo.com/mh/7c/mm/31,29/mn/sn-5hnekn7k,sn-5hne6nzy/ms/au,rdu/mv/m/mvi/2/pl/14/initcwndbps/2267500/vprv/1/playlist_type/DVR/dover/13/txp/4532434/mt/1694772337/fvip/3/short_key/1/keepalive/yes/fexp/24007246,24362685/beids/24350018/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRgIhAIC0iobMnRschmQ3QaYsytXg9eg7l9B_-UNvMciis4bmAiEAg-3jr6SwOfAGCCU-JyTyxcXmraug-hPcjjJzm__43ug%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIhAOlqbgmuueNhIuGENYKCsdwiNAUPheXw-RMUqsiaB7YuAiANN43FxJl14Ve_H_c9K-aDoXG4sI7PDCqKDhov6Qro_g%3D%3D/playlist/index.m3u8', + 'media_content_type': 'VIDEO', + }) +# --- +# name: test_play_media_service[https://soundcloud.com/bruttoband/brutto-11-AUDIO-audio_media_extractor_config] + ReadOnlyDict({ + 'entity_id': 'media_player.bedroom', + 'extra': dict({ + }), + 'media_content_id': 'https://cf-media.sndcdn.com/50remGX1OqRY.128.mp3?Policy=eyJTdGF0ZW1lbnQiOlt7IlJlc291cmNlIjoiKjovL2NmLW1lZGlhLnNuZGNkbi5jb20vNTByZW1HWDFPcVJZLjEyOC5tcDMqIiwiQ29uZGl0aW9uIjp7IkRhdGVMZXNzVGhhbiI6eyJBV1M6RXBvY2hUaW1lIjoxNjk0Nzk5MTc5fX19XX0_&Signature=JtF8BXxTCElhjCrhnSAq3W6z960VmdVXx7BPhQvI0MCxr~J43JFGO8CVw9-VBM2oEf14mqWo63-C0FO29DvUuBZnmLD3dhDfryVfWJsrix7voimoRDaNFE~3zntDbg7O2S8uWYyZK8OZC9anzwokvjH7jbmviWqK4~2IM9dwgejGgzrQU1aadV2Yro7NJZnF7SD~7tVjkM-hBg~X5zDYVxmGrdzN3tFoLwRmUch6RNDL~1DcWBk0AveBKQFAdBrFBjDDUeIyDz9Idhw2aG9~fjfckcf95KwqrVQxz1N5XEzfNDDo8xkUgDt0eb9dtXdwxLJ0swC6e5VLS8bsH91GMg__&Key-Pair-Id=APKAI6TU7MMXM5DG6EPQ', + 'media_content_type': 'AUDIO', + }) +# --- +# name: test_play_media_service[https://soundcloud.com/bruttoband/brutto-11-AUDIO-empty_media_extractor_config] + ReadOnlyDict({ + 'entity_id': 'media_player.bedroom', + 'extra': dict({ + }), + 'media_content_id': 'https://cf-media.sndcdn.com/50remGX1OqRY.128.mp3?Policy=eyJTdGF0ZW1lbnQiOlt7IlJlc291cmNlIjoiKjovL2NmLW1lZGlhLnNuZGNkbi5jb20vNTByZW1HWDFPcVJZLjEyOC5tcDMqIiwiQ29uZGl0aW9uIjp7IkRhdGVMZXNzVGhhbiI6eyJBV1M6RXBvY2hUaW1lIjoxNjk0Nzk4NTkzfX19XX0_&Signature=flALJvEBnzS0ZOOhf0-07Ap~NURw2Gn2OqkeKKTTMX5HRGJw9eXFay79tcC4GsMMXWUgWoCx-n3yelpyilE2MOEIufBNUbjqRfMSJaX5YhYxjQdoDYuiU~gqBzJyPw9pKzr6P8~5HNKL3Idr0CNhUzdV6FQLaUPKMMibq9ghV833mUmdyvdk1~GZBc8MOg9GrTdcigGgpPzd-vrIMICMvFzFnwBOeOotxX2Vfqf9~wVekBKGlvB9A~7TlZ71lv9Fl9u4m8rse9E-mByweVc1M784ehJV3~tRPjuF~FXXWKP8x0nGJmoq7RAnG7iFIt~fQFmsfOq2o~PG7dHMRPh7hw__&Key-Pair-Id=APKAI6TU7MMXM5DG6EPQ', + 'media_content_type': 'AUDIO', + }) +# --- +# name: test_play_media_service[https://soundcloud.com/bruttoband/brutto-11-VIDEO-audio_media_extractor_config] + ReadOnlyDict({ + 'entity_id': 'media_player.bedroom', + 'extra': dict({ + }), + 'media_content_id': 'https://cf-media.sndcdn.com/50remGX1OqRY.128.mp3?Policy=eyJTdGF0ZW1lbnQiOlt7IlJlc291cmNlIjoiKjovL2NmLW1lZGlhLnNuZGNkbi5jb20vNTByZW1HWDFPcVJZLjEyOC5tcDMqIiwiQ29uZGl0aW9uIjp7IkRhdGVMZXNzVGhhbiI6eyJBV1M6RXBvY2hUaW1lIjoxNjk0Nzk5MTc5fX19XX0_&Signature=JtF8BXxTCElhjCrhnSAq3W6z960VmdVXx7BPhQvI0MCxr~J43JFGO8CVw9-VBM2oEf14mqWo63-C0FO29DvUuBZnmLD3dhDfryVfWJsrix7voimoRDaNFE~3zntDbg7O2S8uWYyZK8OZC9anzwokvjH7jbmviWqK4~2IM9dwgejGgzrQU1aadV2Yro7NJZnF7SD~7tVjkM-hBg~X5zDYVxmGrdzN3tFoLwRmUch6RNDL~1DcWBk0AveBKQFAdBrFBjDDUeIyDz9Idhw2aG9~fjfckcf95KwqrVQxz1N5XEzfNDDo8xkUgDt0eb9dtXdwxLJ0swC6e5VLS8bsH91GMg__&Key-Pair-Id=APKAI6TU7MMXM5DG6EPQ', + 'media_content_type': 'VIDEO', + }) +# --- +# name: test_play_media_service[https://soundcloud.com/bruttoband/brutto-11-VIDEO-empty_media_extractor_config] + ReadOnlyDict({ + 'entity_id': 'media_player.bedroom', + 'extra': dict({ + }), + 'media_content_id': 'https://cf-media.sndcdn.com/50remGX1OqRY.128.mp3?Policy=eyJTdGF0ZW1lbnQiOlt7IlJlc291cmNlIjoiKjovL2NmLW1lZGlhLnNuZGNkbi5jb20vNTByZW1HWDFPcVJZLjEyOC5tcDMqIiwiQ29uZGl0aW9uIjp7IkRhdGVMZXNzVGhhbiI6eyJBV1M6RXBvY2hUaW1lIjoxNjk0Nzk4NTkzfX19XX0_&Signature=flALJvEBnzS0ZOOhf0-07Ap~NURw2Gn2OqkeKKTTMX5HRGJw9eXFay79tcC4GsMMXWUgWoCx-n3yelpyilE2MOEIufBNUbjqRfMSJaX5YhYxjQdoDYuiU~gqBzJyPw9pKzr6P8~5HNKL3Idr0CNhUzdV6FQLaUPKMMibq9ghV833mUmdyvdk1~GZBc8MOg9GrTdcigGgpPzd-vrIMICMvFzFnwBOeOotxX2Vfqf9~wVekBKGlvB9A~7TlZ71lv9Fl9u4m8rse9E-mByweVc1M784ehJV3~tRPjuF~FXXWKP8x0nGJmoq7RAnG7iFIt~fQFmsfOq2o~PG7dHMRPh7hw__&Key-Pair-Id=APKAI6TU7MMXM5DG6EPQ', + 'media_content_type': 'VIDEO', + }) +# --- +# name: test_play_media_service[https://test.com/abc-AUDIO-audio_media_extractor_config] + ReadOnlyDict({ + 'entity_id': 'media_player.bedroom', + 'extra': dict({ + }), + 'media_content_id': 'https://cf-media.sndcdn.com/50remGX1OqRY.128.mp3?Policy=eyJTdGF0ZW1lbnQiOlt7IlJlc291cmNlIjoiKjovL2NmLW1lZGlhLnNuZGNkbi5jb20vNTByZW1HWDFPcVJZLjEyOC5tcDMqIiwiQ29uZGl0aW9uIjp7IkRhdGVMZXNzVGhhbiI6eyJBV1M6RXBvY2hUaW1lIjoxNjk0Nzk5MTc5fX19XX0_&Signature=JtF8BXxTCElhjCrhnSAq3W6z960VmdVXx7BPhQvI0MCxr~J43JFGO8CVw9-VBM2oEf14mqWo63-C0FO29DvUuBZnmLD3dhDfryVfWJsrix7voimoRDaNFE~3zntDbg7O2S8uWYyZK8OZC9anzwokvjH7jbmviWqK4~2IM9dwgejGgzrQU1aadV2Yro7NJZnF7SD~7tVjkM-hBg~X5zDYVxmGrdzN3tFoLwRmUch6RNDL~1DcWBk0AveBKQFAdBrFBjDDUeIyDz9Idhw2aG9~fjfckcf95KwqrVQxz1N5XEzfNDDo8xkUgDt0eb9dtXdwxLJ0swC6e5VLS8bsH91GMg__&Key-Pair-Id=APKAI6TU7MMXM5DG6EPQ', + 'media_content_type': 'AUDIO', + }) +# --- +# name: test_play_media_service[https://test.com/abc-AUDIO-empty_media_extractor_config] + ReadOnlyDict({ + 'entity_id': 'media_player.bedroom', + 'extra': dict({ + }), + 'media_content_id': 'https://cf-media.sndcdn.com/50remGX1OqRY.128.mp3?Policy=eyJTdGF0ZW1lbnQiOlt7IlJlc291cmNlIjoiKjovL2NmLW1lZGlhLnNuZGNkbi5jb20vNTByZW1HWDFPcVJZLjEyOC5tcDMqIiwiQ29uZGl0aW9uIjp7IkRhdGVMZXNzVGhhbiI6eyJBV1M6RXBvY2hUaW1lIjoxNjk0Nzk4NTkzfX19XX0_&Signature=flALJvEBnzS0ZOOhf0-07Ap~NURw2Gn2OqkeKKTTMX5HRGJw9eXFay79tcC4GsMMXWUgWoCx-n3yelpyilE2MOEIufBNUbjqRfMSJaX5YhYxjQdoDYuiU~gqBzJyPw9pKzr6P8~5HNKL3Idr0CNhUzdV6FQLaUPKMMibq9ghV833mUmdyvdk1~GZBc8MOg9GrTdcigGgpPzd-vrIMICMvFzFnwBOeOotxX2Vfqf9~wVekBKGlvB9A~7TlZ71lv9Fl9u4m8rse9E-mByweVc1M784ehJV3~tRPjuF~FXXWKP8x0nGJmoq7RAnG7iFIt~fQFmsfOq2o~PG7dHMRPh7hw__&Key-Pair-Id=APKAI6TU7MMXM5DG6EPQ', + 'media_content_type': 'AUDIO', + }) +# --- +# name: test_play_media_service[https://www.youtube.com/watch?v=dQw4w9WgXcQ-VIDEO-audio_media_extractor_config-] + ReadOnlyDict({ + 'entity_id': 'media_player.bedroom', + 'extra': dict({ + }), + 'media_content_id': 'https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805268/ei/tFgEZcu0DoOD-gaqg47wBA/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/616/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D99471214%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D356%3Blmt%3D1694043438471036/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,29/mn/sn-5hne6nzy,sn-5hnekn7k/ms/au,rdu/mv/m/mvi/3/pl/22/initcwndbps/1957500/vprv/1/playlist_type/DVR/dover/13/txp/4532434/mt/1694783146/fvip/2/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRQIhALAASH0_ZDQQoMA82qWNCXSHPZ0bb9TQldIs7AAxktiiAiASA5bQy7IAa6NwdGIOpfye5OgcY_BNuo0WgSdh84tosw%3D%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRgIhAIsDcLGH8KJpQpBgyJ5VWlDxfr75HyO8hMSVS9v7nRu4AiEA2xjtLZOzeNFoJlxwCsH3YqsUQt-BF_4gikhi_P4FbBc%3D/playlist/index.m3u8', + 'media_content_type': 'VIDEO', + }) +# --- +# name: test_play_media_service[https://www.youtube.com/watch?v=dQw4w9WgXcQ-VIDEO-audio_media_extractor_config] + ReadOnlyDict({ + 'entity_id': 'media_player.bedroom', + 'extra': dict({ + }), + 'media_content_id': 'https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805268&ei=tFgEZaHmFN2Px_AP2tSt2AQ&ip=45.93.75.130&id=o-AEj4DudORoGviGzjggo2mjXrQpjRh8L2BrOU-wekY859&itag=22&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hne6nzy%2Csn-5hnekn7k&ms=au%2Crdu&mv=m&mvi=3&pl=22&initcwndbps=1957500&spc=UWF9f7_CV3gS4VV2VFq7hgxtUAyOlog&vprv=1&svpuc=1&mime=video%2Fmp4&cnr=14&ratebypass=yes&dur=212.091&lmt=1694045086815467&mt=1694783146&fvip=2&fexp=24007246&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Ccnr%2Cratebypass%2Cdur%2Clmt&sig=AOq0QJ8wRQIhAO2IJciEtkI3PvYyVC_zkyo61I70wYJQXuGOMueeacrKAiA-UAdaJSlqqkfaa6QtqVnC_BJJZn7BXs85gh_fdbGoSg%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIgRCGi20K-ZvdukYkBZOidcHpGPUpIBOkw-jZGEncsKQECIQC5h-rCfQhDTQFqocOTtQXcNZVA54oIqjweF0mN5GpzFA%3D%3D', + 'media_content_type': 'VIDEO', + }) +# --- +# name: test_play_media_service[https://www.youtube.com/watch?v=dQw4w9WgXcQ-VIDEO-empty_media_extractor_config-] + ReadOnlyDict({ + 'entity_id': 'media_player.bedroom', + 'extra': dict({ + }), + 'media_content_id': 'https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805294/ei/zlgEZcCPFpqOx_APj42f2Ao/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/616/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D99471214%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D356%3Blmt%3D1694043438471036/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-aigzrnld/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/2095000/vprv/1/playlist_type/DVR/dover/13/txp/4532434/mt/1694783390/fvip/1/short_key/1/keepalive/yes/fexp/24007246,24362685/beids/24350017/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRgIhANCPwWNfq6wBp1Xo1L8bRJpDrzOyv7kfH_J65cZ_PRZLAiEAwo-0wQgeIjPe7OgyAAvMCx_A9wd1h8Qyh7VntKwGJUs%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIgIqS9Ub_6L9ScKXr0T9bkeu6TZsEsyNApYfF_MqeukqECIQCMSeJ1sSEw5QGMgHAW8Fhsir4TYHEK5KVg-PzJbrT6hw%3D%3D/playlist/index.m3u8', + 'media_content_type': 'VIDEO', + }) +# --- +# name: test_play_media_service[https://www.youtube.com/watch?v=dQw4w9WgXcQ-VIDEO-empty_media_extractor_config] + ReadOnlyDict({ + 'entity_id': 'media_player.bedroom', + 'extra': dict({ + }), + 'media_content_id': 'https://rr2---sn-5hnekn7k.googlevideo.com/videoplayback?expire=1694805294&ei=zlgEZaLeHcrlgAeFhLrYBA&ip=45.93.75.130&id=o-AFIa6Sil61_wuEFkUVhjKkr-0pyzj2cHi52leur2vR1j&itag=22&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hnekn7k%2Csn-5hne6nzy&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=2095000&spc=UWF9f2Ob7Uhbkv1q69SZBYEqtijLGjs&vprv=1&svpuc=1&mime=video%2Fmp4&cnr=14&ratebypass=yes&dur=212.091&lmt=1694045086815467&mt=1694783390&fvip=3&fexp=24007246%2C24362685&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Ccnr%2Cratebypass%2Cdur%2Clmt&sig=AOq0QJ8wRAIgUiMmQEGPqT5Hb00S74LeTwF4PCN31mwbC_fUNSejdsQCIF2D11o2OXBxoLlOX00vyB1wfYLIo6dBnodrfYc9gH6y&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAI4QpoB0iBj-oMiNFMMdN0RN-u3nLji437a3jqTbhncSAiEAlvsdhJjG0-VZ2jCjyUZBtidBcUzYFwnk6qG7mIiNjCA%3D', + 'media_content_type': 'VIDEO', + }) +# --- +# name: test_playlist + ReadOnlyDict({ + 'entity_id': 'media_player.bedroom', + 'extra': dict({ + }), + 'media_content_id': 'https://rr2---sn-5hne6nzk.googlevideo.com/videoplayback?expire=1694818322&ei=sosEZfXrN8mrx_APirihiAo&ip=45.93.75.130&id=o-AK8fF61bmcIHhl_2kv1XxpCtdRixUPDqG0y6aunrwcZa&itag=18&source=youtube&requiressl=yes&mh=6Q&mm=31%2C29&mn=sn-5hne6nzk%2Csn-5hnednss&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=1868750&spc=UWF9f0JgCQlRLpY93JZnveUdoMCdkmY&vprv=1&svpuc=1&mime=video%2Fmp4&cnr=14&ratebypass=yes&dur=9.055&lmt=1665508348849369&mt=1694796392&fvip=5&fexp=24007246&beids=24350017&c=ANDROID&txp=4438434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Ccnr%2Cratebypass%2Cdur%2Clmt&sig=AOq0QJ8wRQIhALn143d2vS16xd_ndXj_rB8QOeHSCHC9YxSeOaRMF9eWAiAaYxqrRyV5bREBHLPCrs8Wk8Msm3hJrj11OJc2RIEyzw%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAKy1C4o9YUyi7o2_03UfJ8n8vXWgF4t8zB-4FXiAtJ5uAiEAh2chtgFo6quycJIs1kagkaa_AAQbEFrnFU1xEUDEqp4%3D', + 'media_content_type': 'VIDEO', + }) +# --- diff --git a/tests/components/media_extractor/test_init.py b/tests/components/media_extractor/test_init.py new file mode 100644 index 00000000000..c60f67031cf --- /dev/null +++ b/tests/components/media_extractor/test_init.py @@ -0,0 +1,211 @@ +"""The tests for Media Extractor integration.""" +from typing import Any +from unittest.mock import patch + +import pytest +from syrupy import SnapshotAssertion +from yt_dlp import DownloadError + +from homeassistant.components.media_extractor import DOMAIN +from homeassistant.components.media_player import SERVICE_PLAY_MEDIA +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.setup import async_setup_component + +from tests.common import load_json_object_fixture +from tests.components.media_extractor import ( + YOUTUBE_EMPTY_PLAYLIST, + YOUTUBE_PLAYLIST, + YOUTUBE_VIDEO, + MockYoutubeDL, +) +from tests.components.media_extractor.const import NO_FORMATS_RESPONSE, SOUNDCLOUD_TRACK + + +async def test_play_media_service_is_registered(hass: HomeAssistant) -> None: + """Test play media service is registered.""" + await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() + + assert hass.services.has_service(DOMAIN, SERVICE_PLAY_MEDIA) + + +@pytest.mark.parametrize( + "config_fixture", ["empty_media_extractor_config", "audio_media_extractor_config"] +) +@pytest.mark.parametrize( + ("media_content_id", "media_content_type"), + [ + (YOUTUBE_VIDEO, "VIDEO"), + (SOUNDCLOUD_TRACK, "AUDIO"), + (NO_FORMATS_RESPONSE, "AUDIO"), + ], +) +async def test_play_media_service( + hass: HomeAssistant, + mock_youtube_dl: MockYoutubeDL, + calls: list[ServiceCall], + snapshot: SnapshotAssertion, + request: pytest.FixtureRequest, + config_fixture: str, + media_content_id: str, + media_content_type: str, +) -> None: + """Test play media service is registered.""" + config: dict[str, Any] = request.getfixturevalue(config_fixture) + await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + + await hass.services.async_call( + DOMAIN, + SERVICE_PLAY_MEDIA, + { + "entity_id": "media_player.bedroom", + "media_content_type": media_content_type, + "media_content_id": media_content_id, + }, + ) + await hass.async_block_till_done() + + assert calls[0].data == snapshot + + +async def test_download_error( + hass: HomeAssistant, + empty_media_extractor_config: dict[str, Any], + calls: list[ServiceCall], + caplog: pytest.LogCaptureFixture, +) -> None: + """Test handling DownloadError.""" + + with patch( + "homeassistant.components.media_extractor.YoutubeDL.extract_info", + side_effect=DownloadError("Message"), + ): + await async_setup_component(hass, DOMAIN, empty_media_extractor_config) + await hass.async_block_till_done() + + await hass.services.async_call( + DOMAIN, + SERVICE_PLAY_MEDIA, + { + "entity_id": "media_player.bedroom", + "media_content_type": "VIDEO", + "media_content_id": YOUTUBE_VIDEO, + }, + ) + await hass.async_block_till_done() + + assert len(calls) == 0 + assert f"Could not retrieve data for the URL: {YOUTUBE_VIDEO}" in caplog.text + + +async def test_no_target_entity( + hass: HomeAssistant, + mock_youtube_dl: MockYoutubeDL, + empty_media_extractor_config: dict[str, Any], + calls: list[ServiceCall], + snapshot: SnapshotAssertion, +) -> None: + """Test having no target entity.""" + + await async_setup_component(hass, DOMAIN, empty_media_extractor_config) + await hass.async_block_till_done() + + await hass.services.async_call( + DOMAIN, + SERVICE_PLAY_MEDIA, + { + "device_id": "fb034c3a9fefe47c584c32a6b51817eb", + "media_content_type": "VIDEO", + "media_content_id": YOUTUBE_VIDEO, + }, + ) + await hass.async_block_till_done() + + assert calls[0].data == snapshot + + +async def test_playlist( + hass: HomeAssistant, + mock_youtube_dl: MockYoutubeDL, + empty_media_extractor_config: dict[str, Any], + calls: list[ServiceCall], + snapshot: SnapshotAssertion, +) -> None: + """Test extracting a playlist.""" + + await async_setup_component(hass, DOMAIN, empty_media_extractor_config) + await hass.async_block_till_done() + + await hass.services.async_call( + DOMAIN, + SERVICE_PLAY_MEDIA, + { + "entity_id": "media_player.bedroom", + "media_content_type": "VIDEO", + "media_content_id": YOUTUBE_PLAYLIST, + }, + ) + await hass.async_block_till_done() + + assert calls[0].data == snapshot + + +async def test_playlist_no_entries( + hass: HomeAssistant, + mock_youtube_dl: MockYoutubeDL, + empty_media_extractor_config: dict[str, Any], + calls: list[ServiceCall], + caplog: pytest.LogCaptureFixture, +) -> None: + """Test extracting a playlist without entries.""" + + await async_setup_component(hass, DOMAIN, empty_media_extractor_config) + await hass.async_block_till_done() + + await hass.services.async_call( + DOMAIN, + SERVICE_PLAY_MEDIA, + { + "entity_id": "media_player.bedroom", + "media_content_type": "VIDEO", + "media_content_id": YOUTUBE_EMPTY_PLAYLIST, + }, + ) + await hass.async_block_till_done() + + assert len(calls) == 0 + assert ( + f"Could not retrieve data for the URL: {YOUTUBE_EMPTY_PLAYLIST}" in caplog.text + ) + + +async def test_query_error( + hass: HomeAssistant, + empty_media_extractor_config: dict[str, Any], + calls: list[ServiceCall], +) -> None: + """Test handling error with query.""" + + with patch( + "homeassistant.components.media_extractor.YoutubeDL.extract_info", + return_value=load_json_object_fixture("media_extractor/youtube_1_info.json"), + ), patch( + "homeassistant.components.media_extractor.YoutubeDL.process_ie_result", + side_effect=DownloadError("Message"), + ): + await async_setup_component(hass, DOMAIN, empty_media_extractor_config) + await hass.async_block_till_done() + + await hass.services.async_call( + DOMAIN, + SERVICE_PLAY_MEDIA, + { + "entity_id": "media_player.bedroom", + "media_content_type": "VIDEO", + "media_content_id": YOUTUBE_VIDEO, + }, + ) + await hass.async_block_till_done() + + assert len(calls) == 0 diff --git a/tests/components/minecraft_server/const.py b/tests/components/minecraft_server/const.py new file mode 100644 index 00000000000..c7eb0e4b096 --- /dev/null +++ b/tests/components/minecraft_server/const.py @@ -0,0 +1,34 @@ +"""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_PORT = 25566 +TEST_ADDRESS = f"{TEST_HOST}:{TEST_PORT}" + +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 3a201f15bf3..88afa6576d5 100644 --- a/tests/components/minecraft_server/test_config_flow.py +++ b/tests/components/minecraft_server/test_config_flow.py @@ -1,74 +1,20 @@ -"""Test the Minecraft Server config flow.""" +"""Tests for the Minecraft Server config flow.""" -from unittest.mock import AsyncMock, patch +from unittest.mock import patch -import aiodns -from mcstatus.status_response import JavaStatusResponse +from mcstatus import JavaServer -from homeassistant.components.minecraft_server.const import ( - DEFAULT_NAME, - DEFAULT_PORT, - DOMAIN, -) +from homeassistant.components.minecraft_server.const import DEFAULT_NAME, DOMAIN from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT +from homeassistant.const import CONF_ADDRESS, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from tests.common import MockConfigEntry - - -class QueryMock: - """Mock for result of aiodns.DNSResolver.query.""" - - def __init__(self) -> None: - """Set up query result mock.""" - self.host = "mc.dummyserver.com" - 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"}, - ], - }, -} +from .const import TEST_ADDRESS, TEST_HOST, TEST_JAVA_STATUS_RESPONSE, TEST_PORT USER_INPUT = { CONF_NAME: DEFAULT_NAME, - CONF_HOST: f"mc.dummyserver.com:{DEFAULT_PORT}", -} - -USER_INPUT_SRV = {CONF_NAME: DEFAULT_NAME, CONF_HOST: "dummyserver.com"} - -USER_INPUT_IPV4 = { - CONF_NAME: DEFAULT_NAME, - CONF_HOST: f"1.1.1.1:{DEFAULT_PORT}", -} - -USER_INPUT_IPV6 = { - CONF_NAME: DEFAULT_NAME, - CONF_HOST: f"[::ffff:0101:0101]:{DEFAULT_PORT}", -} - -USER_INPUT_PORT_TOO_SMALL = { - CONF_NAME: DEFAULT_NAME, - CONF_HOST: "mc.dummyserver.com:1023", -} - -USER_INPUT_PORT_TOO_LARGE = { - CONF_NAME: DEFAULT_NAME, - CONF_HOST: "mc.dummyserver.com:65536", + CONF_ADDRESS: TEST_ADDRESS, } @@ -82,80 +28,25 @@ 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.""" +async def test_lookup_failed(hass: HomeAssistant) -> None: + """Test error in case of a failed connection.""" 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 - ), + "mcstatus.server.JavaServer.async_lookup", + side_effect=ValueError, ): - 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( - "aiodns.DNSResolver.query", - side_effect=aiodns.error.DNSError, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT_PORT_TOO_SMALL - ) - assert result["type"] == FlowResultType.FORM - assert result["errors"] == {"base": "invalid_port"} - - -async def test_port_too_large(hass: HomeAssistant) -> None: - """Test error in case of a too large port.""" - with patch( - "aiodns.DNSResolver.query", - side_effect=aiodns.error.DNSError, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT_PORT_TOO_LARGE - ) - - assert result["type"] == FlowResultType.FORM - assert result["errors"] == {"base": "invalid_port"} + assert result["errors"] == {"base": "cannot_connect"} async def test_connection_failed(hass: HomeAssistant) -> None: """Test error in case of a failed connection.""" with patch( - "aiodns.DNSResolver.query", - side_effect=aiodns.error.DNSError, + "mcstatus.server.JavaServer.async_lookup", + return_value=JavaServer(host=TEST_HOST, port=TEST_PORT), ), patch("mcstatus.server.JavaServer.async_status", side_effect=OSError): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT @@ -165,85 +56,20 @@ async def test_connection_failed(hass: HomeAssistant) -> None: assert result["errors"] == {"base": "cannot_connect"} -async def test_connection_succeeded_with_srv_record(hass: HomeAssistant) -> None: - """Test config entry in case of a successful connection with a SRV record.""" - with patch( - "aiodns.DNSResolver.query", - side_effect=AsyncMock(return_value=[QueryMock()]), - ), patch( - "mcstatus.server.JavaServer.async_status", - return_value=JavaStatusResponse( - None, None, None, None, JAVA_STATUS_RESPONSE_RAW, None - ), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT_SRV - ) - - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == USER_INPUT_SRV[CONF_HOST] - assert result["data"][CONF_NAME] == USER_INPUT_SRV[CONF_NAME] - assert result["data"][CONF_HOST] == USER_INPUT_SRV[CONF_HOST] - - -async def test_connection_succeeded_with_host(hass: HomeAssistant) -> None: +async def test_connection_succeeded(hass: HomeAssistant) -> None: """Test config entry in case of a successful connection with a host name.""" with patch( - "aiodns.DNSResolver.query", - side_effect=aiodns.error.DNSError, + "mcstatus.server.JavaServer.async_lookup", + return_value=JavaServer(host=TEST_HOST, port=TEST_PORT), ), 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 ) assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == USER_INPUT[CONF_HOST] + assert result["title"] == USER_INPUT[CONF_ADDRESS] assert result["data"][CONF_NAME] == USER_INPUT[CONF_NAME] - assert result["data"][CONF_HOST] == "mc.dummyserver.com" - - -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( - "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 - ), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT_IPV4 - ) - - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == USER_INPUT_IPV4[CONF_HOST] - assert result["data"][CONF_NAME] == USER_INPUT_IPV4[CONF_NAME] - assert result["data"][CONF_HOST] == "1.1.1.1" - - -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( - "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 - ), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT_IPV6 - ) - - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == USER_INPUT_IPV6[CONF_HOST] - assert result["data"][CONF_NAME] == USER_INPUT_IPV6[CONF_NAME] - assert result["data"][CONF_HOST] == "::ffff:0101:0101" + assert result["data"][CONF_ADDRESS] == TEST_ADDRESS diff --git a/tests/components/minecraft_server/test_init.py b/tests/components/minecraft_server/test_init.py new file mode 100644 index 00000000000..1e3679fb1e3 --- /dev/null +++ b/tests/components/minecraft_server/test_init.py @@ -0,0 +1,219 @@ +"""Tests for the Minecraft Server integration.""" +from unittest.mock import patch + +from mcstatus import JavaServer + +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.minecraft_server.const import DEFAULT_NAME, DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.const import CONF_ADDRESS, 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_ADDRESS, TEST_HOST, TEST_JAVA_STATUS_RESPONSE, TEST_PORT + +from tests.common import MockConfigEntry + +TEST_UNIQUE_ID = f"{TEST_HOST}-{TEST_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"} + + +def create_v1_mock_config_entry(hass: HomeAssistant) -> int: + """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: TEST_PORT, + }, + version=1, + ) + config_entry_id = config_entry_v1.entry_id + config_entry_v1.add_to_hass(hass) + + return config_entry_id + + +def create_v1_mock_device_entry(hass: HomeAssistant, config_entry_id: int) -> int: + """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 + + return device_entry_id + + +def create_v1_mock_sensor_entity_entries( + hass: HomeAssistant, config_entry_id: int, device_entry_id: int +) -> list[dict]: + """Create mock sensor entity entries.""" + sensor_entity_id_key_mapping_list = [] + config_entry = hass.config_entries.async_get_entry(config_entry_id) + 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, + 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"]} + ) + + return sensor_entity_id_key_mapping_list + + +def create_v1_mock_binary_sensor_entity_entry( + hass: HomeAssistant, config_entry_id: int, device_entry_id: int +) -> dict: + """Create mock binary sensor entity entry.""" + config_entry = hass.config_entries.async_get_entry(config_entry_id) + entity_registry = er.async_get(hass) + entity_unique_id = f"{TEST_UNIQUE_ID}-{BINARY_SENSOR_KEYS['v1']}" + entity_entry = entity_registry.async_get_or_create( + BINARY_SENSOR_DOMAIN, + DOMAIN, + unique_id=entity_unique_id, + config_entry=config_entry, + device_id=device_entry_id, + ) + assert entity_entry.unique_id == entity_unique_id + binary_sensor_entity_id_key_mapping = { + "entity_id": entity_entry.entity_id, + "key": BINARY_SENSOR_KEYS["v2"], + } + + return binary_sensor_entity_id_key_mapping + + +async def test_entry_migration(hass: HomeAssistant) -> None: + """Test entry migration from version 1 to 3, where host and port is required for the connection to the server.""" + config_entry_id = create_v1_mock_config_entry(hass) + device_entry_id = create_v1_mock_device_entry(hass, config_entry_id) + sensor_entity_id_key_mapping_list = create_v1_mock_sensor_entity_entries( + hass, config_entry_id, device_entry_id + ) + binary_sensor_entity_id_key_mapping = create_v1_mock_binary_sensor_entity_entry( + hass, config_entry_id, device_entry_id + ) + + # Trigger migration. + with patch( + "mcstatus.server.JavaServer.lookup", + side_effect=[ + ValueError, + JavaServer(host=TEST_HOST, port=TEST_PORT), + JavaServer(host=TEST_HOST, port=TEST_PORT), + ], + ), 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 = hass.config_entries.async_get_entry(config_entry_id) + assert config_entry.unique_id is None + assert config_entry.data == { + CONF_NAME: DEFAULT_NAME, + CONF_ADDRESS: TEST_ADDRESS, + } + assert config_entry.version == 3 + + # Test migrated device entry. + device_registry = dr.async_get(hass) + device_entry = device_registry.async_get(device_entry_id) + assert device_entry.identifiers == {(DOMAIN, config_entry_id)} + + # Test migrated sensor entity entries. + entity_registry = er.async_get(hass) + for mapping in sensor_entity_id_key_mapping_list: + entity_entry = entity_registry.async_get(mapping["entity_id"]) + assert entity_entry.unique_id == f"{config_entry_id}-{mapping['key']}" + + # Test migrated binary sensor entity entry. + entity_entry = entity_registry.async_get( + binary_sensor_entity_id_key_mapping["entity_id"] + ) + assert ( + entity_entry.unique_id + == f"{config_entry_id}-{binary_sensor_entity_id_key_mapping['key']}" + ) + + +async def test_entry_migration_host_only(hass: HomeAssistant) -> None: + """Test entry migration from version 1 to 3, where host alone is sufficient for the connection to the server.""" + config_entry_id = create_v1_mock_config_entry(hass) + device_entry_id = create_v1_mock_device_entry(hass, config_entry_id) + create_v1_mock_sensor_entity_entries(hass, config_entry_id, device_entry_id) + create_v1_mock_binary_sensor_entity_entry(hass, config_entry_id, device_entry_id) + + # Trigger migration. + with patch( + "mcstatus.server.JavaServer.lookup", + side_effect=[ + JavaServer(host=TEST_HOST, port=TEST_PORT), + JavaServer(host=TEST_HOST, port=TEST_PORT), + ], + ), 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 = hass.config_entries.async_get_entry(config_entry_id) + assert config_entry.unique_id is None + assert config_entry.data == { + CONF_NAME: DEFAULT_NAME, + CONF_ADDRESS: TEST_HOST, + } + assert config_entry.version == 3 + + +async def test_entry_migration_v3_failure(hass: HomeAssistant) -> None: + """Test failed entry migration from version 2 to 3.""" + config_entry_id = create_v1_mock_config_entry(hass) + device_entry_id = create_v1_mock_device_entry(hass, config_entry_id) + create_v1_mock_sensor_entity_entries(hass, config_entry_id, device_entry_id) + create_v1_mock_binary_sensor_entity_entry(hass, config_entry_id, device_entry_id) + + # Trigger migration. + with patch( + "mcstatus.server.JavaServer.lookup", + side_effect=[ + ValueError, + ValueError, + ], + ): + assert not await hass.config_entries.async_setup(config_entry_id) + await hass.async_block_till_done() + + # Test config entry. + config_entry = hass.config_entries.async_get_entry(config_entry_id) + assert config_entry.version == 2 diff --git a/tests/components/mobile_app/conftest.py b/tests/components/mobile_app/conftest.py index e7c9ad4995a..f69912f176c 100644 --- a/tests/components/mobile_app/conftest.py +++ b/tests/components/mobile_app/conftest.py @@ -10,18 +10,16 @@ from .const import REGISTER, REGISTER_CLEARTEXT @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 4faf48e2118..9f6aec404e2 100644 --- a/tests/components/mobile_app/test_webhook.py +++ b/tests/components/mobile_app/test_webhook.py @@ -196,9 +196,9 @@ async def test_webhook_handle_fire_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 d4c7dfa5e10..d7e4556f746 100644 --- a/tests/components/modbus/conftest.py +++ b/tests/components/modbus/conftest.py @@ -10,7 +10,7 @@ from pymodbus.exceptions import ModbusException 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 @@ class ReadResult: """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") diff --git a/tests/components/modbus/test_binary_sensor.py b/tests/components/modbus/test_binary_sensor.py index 1e413fcc764..2069aa23b8f 100644 --- a/tests/components/modbus/test_binary_sensor.py +++ b/tests/components/modbus/test_binary_sensor.py @@ -8,9 +8,11 @@ from homeassistant.components.modbus.const import ( 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 @@ SLAVE_UNIQUE_ID = "ground_floor_sensor" } ] }, + { + 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 @@ SLAVE_UNIQUE_ID = "ground_floor_sensor" } ] }, + { + 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 @@ ENTITY_ID2 = f"{ENTITY_ID}_1" CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 51, CONF_SCAN_INTERVAL: 0, - CONF_SLAVE_COUNT: 1, + CONF_VIRTUAL_COUNT: 1, } ] }, @@ -294,9 +318,18 @@ TEST_NAME = "test_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 4ab78df0c81..f2de0177c74 100644 --- a/tests/components/modbus/test_climate.py +++ b/tests/components/modbus/test_climate.py @@ -11,6 +11,7 @@ from homeassistant.components.climate.const import ( 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 @@ ENTITY_ID = f"{CLIMATE_DOMAIN}.{TEST_ENTITY_NAME}".replace(" ", "_") } ], }, + { + 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 66e4537d67e..b91b38b1f70 100644 --- a/tests/components/modbus/test_cover.py +++ b/tests/components/modbus/test_cover.py @@ -7,6 +7,7 @@ from homeassistant.components.cover import DOMAIN as COVER_DOMAIN 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 @@ ENTITY_ID2 = f"{ENTITY_ID}_2" } ] }, + { + 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 2d2cc83162d..932e07b2d1a 100644 --- a/tests/components/modbus/test_fan.py +++ b/tests/components/modbus/test_fan.py @@ -8,6 +8,7 @@ from homeassistant.components.modbus.const import ( 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 @@ ENTITY_ID2 = f"{ENTITY_ID}_2" } ] }, + { + 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 6f88a4b7399..e66115f24d9 100644 --- a/tests/components/modbus/test_init.py +++ b/tests/components/modbus/test_init.py @@ -40,16 +40,20 @@ from homeassistant.components.modbus.const import ( 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 @@ SERVICE = "service" ], ) @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 46763b3b3a2..1d6963aaa12 100644 --- a/tests/components/modbus/test_light.py +++ b/tests/components/modbus/test_light.py @@ -8,6 +8,7 @@ from homeassistant.components.modbus.const import ( 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 @@ ENTITY_ID2 = f"{ENTITY_ID}_2" } ] }, + { + 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 de390d126fe..0f79a125c86 100644 --- a/tests/components/modbus/test_sensor.py +++ b/tests/components/modbus/test_sensor.py @@ -8,6 +8,7 @@ from homeassistant.components.modbus.const import ( CALL_TYPE_REGISTER_HOLDING, CALL_TYPE_REGISTER_INPUT, CONF_DATA_TYPE, + CONF_DEVICE_ADDRESS, CONF_INPUT_TYPE, CONF_LAZY_ERROR, CONF_MAX_VALUE, @@ -21,6 +22,7 @@ from homeassistant.components.modbus.const import ( CONF_SWAP_NONE, CONF_SWAP_WORD, CONF_SWAP_WORD_BYTE, + CONF_VIRTUAL_COUNT, CONF_ZERO_SUPPRESS, MODBUS_DOMAIN, DataType, @@ -85,6 +87,23 @@ SLAVE_UNIQUE_ID = "ground_floor_sensor" } ] }, + { + 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: [ { @@ -150,6 +169,16 @@ SLAVE_UNIQUE_ID = "ground_floor_sensor" } ] }, + { + 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: @@ -189,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", ), ( { @@ -214,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}`", ), ( { @@ -229,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", ), ( { @@ -249,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}`", ), ], ) @@ -382,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, @@ -600,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: @@ -641,6 +711,21 @@ async def test_all_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> None: 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, @@ -650,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, @@ -659,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, @@ -682,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, @@ -691,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, @@ -700,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)): @@ -736,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, @@ -747,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, @@ -758,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, @@ -769,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, @@ -780,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, @@ -791,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, @@ -802,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, @@ -813,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, @@ -838,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, @@ -871,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(" ", "_") @@ -1120,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, }, @@ -1129,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, }, @@ -1138,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, }, @@ -1147,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, }, @@ -1156,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, }, @@ -1165,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, }, @@ -1206,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 7a79e19869a..0eb40d2c082 100644 --- a/tests/components/modbus/test_switch.py +++ b/tests/components/modbus/test_switch.py @@ -11,6 +11,7 @@ from homeassistant.components.modbus.const import ( 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 @@ ENTITY_ID2 = f"{ENTITY_ID}_2" } ] }, + { + 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: [ { @@ -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 540a8fef93d..49bac6a5bb0 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 ebe86c1f1df..91ece381f6d 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 @@ from tests.components.light.conftest import mock_light_profiles # noqa: F401 @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_alarm_control_panel.py b/tests/components/mqtt/test_alarm_control_panel.py index 35fba9e2a0c..7532319854a 100644 --- a/tests/components/mqtt/test_alarm_control_panel.py +++ b/tests/components/mqtt/test_alarm_control_panel.py @@ -62,6 +62,7 @@ from .test_common import ( 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, @@ -1232,3 +1233,39 @@ 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( + alarm_control_panel.DOMAIN, + DEFAULT_CONFIG, + ( + { + "availability_topic": "availability-topic", + "json_attributes_topic": "json-attributes-topic", + "state_topic": "test-topic", + }, + ), + ) + ], +) +@pytest.mark.parametrize( + ("topic", "payload1", "payload2"), + [ + ("test-topic", STATE_ALARM_DISARMED, STATE_ALARM_ARMED_HOME), + ("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_binary_sensor.py b/tests/components/mqtt/test_binary_sensor.py index 28bf5f558cb..ea9c8072290 100644 --- a/tests/components/mqtt/test_binary_sensor.py +++ b/tests/components/mqtt/test_binary_sensor.py @@ -6,6 +6,7 @@ from pathlib import Path 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 @@ from .test_common import ( 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_climate.py b/tests/components/mqtt/test_climate.py index 9e0363b3611..9c0adbe2adf 100644 --- a/tests/components/mqtt/test_climate.py +++ b/tests/components/mqtt/test_climate.py @@ -58,6 +58,7 @@ from .test_common import ( 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, @@ -2555,3 +2556,60 @@ async def test_unload_entry( await help_test_unload_config_entry_with_platform( hass, mqtt_mock_entry, domain, config ) + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + climate.DOMAIN, + DEFAULT_CONFIG, + ( + { + "availability_topic": "availability-topic", + "json_attributes_topic": "json-attributes-topic", + "action_topic": "action-topic", + "fan_mode_state_topic": "fan-mode-state-topic", + "mode_state_topic": "mode-state-topic", + "current_humidity_topic": "current-humidity-topic", + "current_temperature_topic": "current-temperature-topic", + "preset_mode_state_topic": "preset-mode-state-topic", + "preset_modes": ["eco", "away"], + "swing_mode_state_topic": "swing-mode-state-topic", + "target_humidity_state_topic": "target-humidity-state-topic", + "temperature_high_state_topic": "temperature-high-state-topic", + "temperature_low_state_topic": "temperature-low-state-topic", + "temperature_state_topic": "temperature-state-topic", + }, + ), + ) + ], +) +@pytest.mark.parametrize( + ("topic", "payload1", "payload2"), + [ + ("availability-topic", "online", "offline"), + ("json-attributes-topic", '{"attr1": "val1"}', '{"attr1": "val2"}'), + ("action-topic", "cooling", "heating"), + ("fan-mode-state-topic", "low", "medium"), + ("mode-state-topic", "cool", "heat"), + ("current-humidity-topic", "45", "46"), + ("current-temperature-topic", "18.0", "18.1"), + ("preset-mode-state-topic", "eco", "away"), + ("swing-mode-state-topic", "on", "off"), + ("target-humidity-state-topic", "45", "50"), + ("temperature-state-topic", "18", "19"), + ("temperature-low-state-topic", "18", "19"), + ("temperature-high-state-topic", "18", "19"), + ], +) +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 9aa88c2d7ba..64bece5369e 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 f0681a537da..c2a7e0065ce 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(tmp_path: Path) -> Generator[MagicMock, None, Non 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_cover.py b/tests/components/mqtt/test_cover.py index 2eec5f8374b..74dc48f4402 100644 --- a/tests/components/mqtt/test_cover.py +++ b/tests/components/mqtt/test_cover.py @@ -47,6 +47,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from .test_common import ( + help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, help_test_custom_availability_payload, @@ -69,6 +70,7 @@ from .test_common import ( 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, @@ -3666,3 +3668,43 @@ async def test_unload_entry( await help_test_unload_config_entry_with_platform( hass, mqtt_mock_entry, domain, config ) + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + cover.DOMAIN, + DEFAULT_CONFIG, + ( + { + "availability_topic": "availability-topic", + "json_attributes_topic": "json-attributes-topic", + "state_topic": "test-topic", + "position_topic": "position-topic", + "tilt_status_topic": "tilt-status-topic", + }, + ), + ) + ], +) +@pytest.mark.parametrize( + ("topic", "payload1", "payload2"), + [ + ("test-topic", "open", "closed"), + ("availability-topic", "online", "offline"), + ("json-attributes-topic", '{"attr1": "val1"}', '{"attr1": "val2"}'), + ("position-topic", "50", "100"), + ("tilt-status-topic", "50", "100"), + ], +) +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_device_tracker.py b/tests/components/mqtt/test_device_tracker.py index 8485e5578fe..204b149e479 100644 --- a/tests/components/mqtt/test_device_tracker.py +++ b/tests/components/mqtt/test_device_tracker.py @@ -13,8 +13,10 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component from .test_common import ( + help_custom_config, help_test_reloadable, help_test_setting_blocked_attribute_via_mqtt_json_message, + help_test_skipped_async_ha_write_state, ) from tests.common import async_fire_mqtt_message @@ -636,3 +638,38 @@ async def test_reloadable( domain = device_tracker.DOMAIN config = DEFAULT_CONFIG await help_test_reloadable(hass, mqtt_client_mock, domain, config) + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + device_tracker.DOMAIN, + DEFAULT_CONFIG, + ( + { + "availability_topic": "availability-topic", + "json_attributes_topic": "json-attributes-topic", + }, + ), + ) + ], +) +@pytest.mark.parametrize( + ("topic", "payload1", "payload2"), + [ + ("test-topic", "home", "work"), + ("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_event.py b/tests/components/mqtt/test_event.py index abcd6e8f3ee..37a17ac9a41 100644 --- a/tests/components/mqtt/test_event.py +++ b/tests/components/mqtt/test_event.py @@ -13,6 +13,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from .test_common import ( + help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, help_test_custom_availability_payload, @@ -42,6 +43,7 @@ from .test_common import ( 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, @@ -668,3 +670,73 @@ 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( + event.DOMAIN, + DEFAULT_CONFIG, + ( + { + "availability_topic": "availability-topic", + "json_attributes_topic": "json-attributes-topic", + }, + ), + ) + ], +) +@pytest.mark.parametrize( + ("topic", "payload1", "payload2"), + [ + ("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) + + +@pytest.mark.freeze_time("2023-09-01 00:00:00+00:00") +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_skipped_async_ha_write_state2( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + freezer: FrozenDateTimeFactory, +) -> None: + """Test a write state command is only called when there is a valid event.""" + await mqtt_mock_entry() + topic = "test-topic" + payload1 = '{"event_type": "press"}' + payload2 = '{"event_type": "unknown"}' + 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 + + freezer.move_to("2023-09-01 00:00:10+00:00") + async_fire_mqtt_message(hass, topic, payload1) + await hass.async_block_till_done() + assert len(mock_async_ha_write_state.mock_calls) == 2 + + freezer.move_to("2023-09-01 00:00:20+00:00") + async_fire_mqtt_message(hass, topic, payload2) + await hass.async_block_till_done() + assert len(mock_async_ha_write_state.mock_calls) == 2 + + freezer.move_to("2023-09-01 00:00:30+00:00") + 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_fan.py b/tests/components/mqtt/test_fan.py index 803a0d74766..fe354817aef 100644 --- a/tests/components/mqtt/test_fan.py +++ b/tests/components/mqtt/test_fan.py @@ -60,6 +60,7 @@ from .test_common import ( 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, @@ -2244,3 +2245,48 @@ async def test_unload_entry( await help_test_unload_config_entry_with_platform( hass, mqtt_mock_entry, domain, config ) + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + fan.DOMAIN, + DEFAULT_CONFIG, + ( + { + "availability_topic": "availability-topic", + "json_attributes_topic": "json-attributes-topic", + "direction_state_topic": "direction-state-topic", + "percentage_state_topic": "percentage-state-topic", + "preset_mode_command_topic": "preset-mode-command-topic", + "preset_mode_state_topic": "preset-mode-state-topic", + "preset_modes": ["eco", "silent"], + "oscillation_state_topic": "oscillation-state-topic", + }, + ), + ) + ], +) +@pytest.mark.parametrize( + ("topic", "payload1", "payload2"), + [ + ("availability-topic", "online", "offline"), + ("json-attributes-topic", '{"attr1": "val1"}', '{"attr1": "val2"}'), + ("state-topic", "ON", "OFF"), + ("direction-state-topic", "forward", "reverse"), + ("percentage-state-topic", "30", "40"), + ("preset-mode-state-topic", "eco", "silent"), + ("oscillation-state-topic", "oscillate_on", "oscillate_off"), + ], +) +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_humidifier.py b/tests/components/mqtt/test_humidifier.py index 0cc4d936841..4d2637a264f 100644 --- a/tests/components/mqtt/test_humidifier.py +++ b/tests/components/mqtt/test_humidifier.py @@ -38,6 +38,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from .test_common import ( + help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, help_test_custom_availability_payload, @@ -60,6 +61,7 @@ from .test_common import ( 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, @@ -1569,3 +1571,51 @@ async def test_unload_config_entry( await help_test_unload_config_entry_with_platform( hass, mqtt_mock_entry, domain, config ) + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + humidifier.DOMAIN, + DEFAULT_CONFIG, + ( + { + "availability_topic": "availability-topic", + "json_attributes_topic": "json-attributes-topic", + "action_topic": "action-topic", + "target_humidity_state_topic": "target-humidity-state-topic", + "current_humidity_topic": "current-humidity-topic", + "mode_command_topic": "mode-command-topic", + "mode_state_topic": "mode-state-topic", + "modes": [ + "comfort", + "eco", + ], + }, + ), + ) + ], +) +@pytest.mark.parametrize( + ("topic", "payload1", "payload2"), + [ + ("availability-topic", "online", "offline"), + ("json-attributes-topic", '{"attr1": "val1"}', '{"attr1": "val2"}'), + ("state-topic", "ON", "OFF"), + ("action-topic", "idle", "humidifying"), + ("current-humidity-topic", "31", "32"), + ("target-humidity-state-topic", "30", "40"), + ("mode-state-topic", "comfort", "eco"), + ], +) +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_image.py b/tests/components/mqtt/test_image.py index d5789880f73..621be984b7b 100644 --- a/tests/components/mqtt/test_image.py +++ b/tests/components/mqtt/test_image.py @@ -15,6 +15,7 @@ from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from .test_common import ( + help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, help_test_custom_availability_payload, @@ -34,6 +35,7 @@ from .test_common import ( 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, @@ -810,3 +812,37 @@ async def test_unload_entry( await help_test_unload_config_entry_with_platform( hass, mqtt_mock_entry, domain, config ) + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + image.DOMAIN, + DEFAULT_CONFIG, + ( + { + "availability_topic": "availability-topic", + "json_attributes_topic": "json-attributes-topic", + }, + ), + ) + ], +) +@pytest.mark.parametrize( + ("topic", "payload1", "payload2"), + [ + ("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_init.py b/tests/components/mqtt/test_init.py index e3a12a2c24e..48d949ae927 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -3898,3 +3898,44 @@ async def test_reload_config_entry( assert state.state == "manual2_update_after_reload" assert (state := hass.states.get("sensor.test_manual3")) is not None assert state.state is STATE_UNAVAILABLE + + +@pytest.mark.parametrize( + "hass_config", + [ + { + "mqtt": [ + { + "sensor": { + "name": "test", + "state_topic": "test-topic", + } + }, + ] + } + ], +) +async def test_reload_with_invalid_config( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test reloading yaml config fails.""" + await mqtt_mock_entry() + assert hass.states.get("sensor.test") is not None + + # Reload with an invalid config and assert again + invalid_config = {"mqtt": "some_invalid_config"} + with patch( + "homeassistant.config.load_yaml_config_file", return_value=invalid_config + ): + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + "mqtt", + SERVICE_RELOAD, + {}, + blocking=True, + ) + await hass.async_block_till_done() + + # Test nothing changed as loading the config failed + assert hass.states.get("sensor.test") is not None diff --git a/tests/components/mqtt/test_lawn_mower.py b/tests/components/mqtt/test_lawn_mower.py index b7130cac3bf..85df2caef6c 100644 --- a/tests/components/mqtt/test_lawn_mower.py +++ b/tests/components/mqtt/test_lawn_mower.py @@ -24,6 +24,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, State from .test_common import ( + help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, help_test_custom_availability_payload, @@ -47,6 +48,7 @@ from .test_common import ( 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, @@ -886,3 +888,39 @@ async def test_persistent_state_after_reconfig( # assert the state persistent state = hass.states.get("lawn_mower.garden") assert state.state == "docked" + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + lawn_mower.DOMAIN, + DEFAULT_CONFIG, + ( + { + "activity_state_topic": "activity-state-topic", + "availability_topic": "availability-topic", + "json_attributes_topic": "json-attributes-topic", + }, + ), + ) + ], +) +@pytest.mark.parametrize( + ("topic", "payload1", "payload2"), + [ + ("activity-state-topic", "mowing", "paused"), + ("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_legacy_vacuum.py b/tests/components/mqtt/test_legacy_vacuum.py index 85e3bdd12b9..c7d17ed47a0 100644 --- a/tests/components/mqtt/test_legacy_vacuum.py +++ b/tests/components/mqtt/test_legacy_vacuum.py @@ -63,6 +63,7 @@ from .test_common import ( 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_update_with_json_attrs_bad_json, help_test_update_with_json_attrs_not_dict, @@ -1099,3 +1100,43 @@ async def test_setup_manual_entity_from_yaml( await mqtt_mock_entry() platform = vacuum.DOMAIN assert hass.states.get(f"{platform}.mqtttest") + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + vacuum.DOMAIN, + DEFAULT_CONFIG, + ( + { + "availability_topic": "availability-topic", + "json_attributes_topic": "json-attributes-topic", + }, + ), + ) + ], +) +@pytest.mark.parametrize( + ("topic", "payload1", "payload2"), + [ + ("availability-topic", "online", "offline"), + ("json-attributes-topic", '{"attr1": "val1"}', '{"attr1": "val2"}'), + ("vacuum/state", '{"battery_level": 71}', '{"battery_level": 60}'), + ("vacuum/state", '{"docked": true}', '{"docked": false}'), + ("vacuum/state", '{"cleaning": true}', '{"cleaning": false}'), + ("vacuum/state", '{"fan_speed": "max"}', '{"fan_speed": "min"}'), + ("vacuum/state", '{"error": "some error"}', '{"error": "other error"}'), + ("vacuum/state", '{"charging": true}', '{"charging": false}'), + ], +) +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_light.py b/tests/components/mqtt/test_light.py index 133f38c1a56..58d37943403 100644 --- a/tests/components/mqtt/test_light.py +++ b/tests/components/mqtt/test_light.py @@ -221,6 +221,7 @@ from .test_common import ( 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, @@ -3346,9 +3347,9 @@ async def test_reloadable( ("state_topic", "ON", None, "on", None), ( "color_mode_state_topic", - "200", + "rgb", "color_mode", - "200", + "rgb", ("state_topic", "ON"), ), ("color_temp_state_topic", "200", "color_temp", 200, ("state_topic", "ON")), @@ -3635,3 +3636,59 @@ async def test_unload_entry( await help_test_unload_config_entry_with_platform( hass, mqtt_mock_entry, domain, config ) + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + light.DOMAIN, + DEFAULT_CONFIG, + ( + { + "availability_topic": "availability-topic", + "json_attributes_topic": "json-attributes-topic", + "state_topic": "test-topic", + "state_value_template": "{{ value_json.state }}", + "brightness_state_topic": "brightness-state-topic", + "color_mode_state_topic": "color-mode-state-topic", + "color_temp_state_topic": "color-temp-state-topic", + "effect_state_topic": "effect-state-topic", + "effect_list": ["effect1", "effect2"], + "hs_state_topic": "hs-state-topic", + "xy_state_topic": "xy-state-topic", + "rgb_state_topic": "rgb-state-topic", + "rgbw_state_topic": "rgbw-state-topic", + "rgbww_state_topic": "rgbww-state-topic", + }, + ), + ) + ], +) +@pytest.mark.parametrize( + ("topic", "payload1", "payload2"), + [ + ("test-topic", '{"state":"ON"}', '{"state":"OFF"}'), + ("availability-topic", "online", "offline"), + ("json-attributes-topic", '{"attr1": "val1"}', '{"attr1": "val2"}'), + ("brightness-state-topic", "50", "100"), + ("color-mode-state-topic", "rgb", "color_temp"), + ("color-temp-state-topic", "800", "200"), + ("effect-state-topic", "effect1", "effect2"), + ("hs-state-topic", "210,50", "200,50"), + ("xy-state-topic", "128,128", "96,96"), + ("rgb-state-topic", "128,128,128", "128,128,64"), + ("rgbw-state-topic", "128,128,128,255", "128,128,128,128"), + ("rgbww-state-topic", "128,128,128,32,255", "128,128,128,64,255"), + ], +) +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_light_json.py b/tests/components/mqtt/test_light_json.py index 7ff4ccbab85..3b44f86460f 100644 --- a/tests/components/mqtt/test_light_json.py +++ b/tests/components/mqtt/test_light_json.py @@ -124,6 +124,7 @@ from .test_common import ( 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_update_with_json_attrs_bad_json, help_test_update_with_json_attrs_not_dict, @@ -2453,3 +2454,96 @@ async def test_setup_manual_entity_from_yaml( await mqtt_mock_entry() platform = light.DOMAIN assert hass.states.get(f"{platform}.test") + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + light.DOMAIN, + DEFAULT_CONFIG, + ( + { + "color_mode": True, + "effect": True, + "supported_color_modes": [ + "color_temp", + "hs", + "xy", + "rgb", + "rgbw", + "rgbww", + "white", + ], + "effect_list": ["effect1", "effect2"], + "availability_topic": "availability-topic", + "json_attributes_topic": "json-attributes-topic", + "state_topic": "test-topic", + }, + ), + ) + ], +) +@pytest.mark.parametrize( + ("topic", "payload1", "payload2"), + [ + ("test-topic", '{"state":"ON"}', '{"state":"OFF"}'), + ("availability-topic", "online", "offline"), + ("json-attributes-topic", '{"attr1": "val1"}', '{"attr1": "val2"}'), + ( + "test-topic", + '{"state":"ON","effect":"effect1"}', + '{"state":"ON","effect":"effect2"}', + ), + ( + "test-topic", + '{"state":"ON","brightness":255}', + '{"state":"ON","brightness":96}', + ), + ( + "test-topic", + '{"state":"ON","brightness":96}', + '{"state":"ON","color_mode":"white","brightness":96}', + ), + ( + "test-topic", + '{"state":"ON","color_mode":"color_temp", "color_temp": 200}', + '{"state":"ON","color_mode":"color_temp", "color_temp": 2400}', + ), + ( + "test-topic", + '{"state":"ON","color_mode":"hs", "color": {"h":24.0,"s":100.0}}', + '{"state":"ON","color_mode":"hs", "color": {"h":24.0,"s":90.0}}', + ), + ( + "test-topic", + '{"state":"ON","color_mode":"xy","color": {"x":0.14,"y":0.131}}', + '{"state":"ON","color_mode":"xy","color": {"x":0.16,"y": 0.100}}', + ), + ( + "test-topic", + '{"state":"ON","brightness":255,"color_mode":"rgb","color":{"r":128,"g":128,"b":255}}', + '{"state":"ON","brightness":255,"color_mode":"rgb","color": {"r":255,"g":128,"b":255}}', + ), + ( + "test-topic", + '{"state":"ON","color_mode":"rgbw","color":{"r":128,"g":128,"b":255,"w":128}}', + '{"state":"ON","color_mode":"rgbw","color": {"r":128,"g":128,"b":255,"w":255}}', + ), + ( + "test-topic", + '{"state":"ON","color_mode":"rgbww","color":{"r":128,"g":128,"b":255,"c":32,"w":128}}', + '{"state":"ON","color_mode":"rgbww","color": {"r":128,"g":128,"b":255,"c":16,"w":128}}', + ), + ], +) +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_light_template.py b/tests/components/mqtt/test_light_template.py index 0583a1176b6..f9f355025e9 100644 --- a/tests/components/mqtt/test_light_template.py +++ b/tests/components/mqtt/test_light_template.py @@ -46,6 +46,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, State from .test_common import ( + help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, help_test_custom_availability_payload, @@ -68,6 +69,7 @@ from .test_common import ( 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, @@ -1378,3 +1380,67 @@ async def test_unload_entry( await help_test_unload_config_entry_with_platform( hass, mqtt_mock_entry, domain, config ) + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + light.DOMAIN, + DEFAULT_CONFIG, + ( + { + "availability_topic": "availability-topic", + "json_attributes_topic": "json-attributes-topic", + "state_topic": "test-topic", + "state_template": "{{ value_json.state }}", + "brightness_template": "{{ value_json.brightness }}", + "color_temp_template": "{{ value_json.color_temp }}", + "effect_template": "{{ value_json.effect }}", + "red_template": "{{ value_json.r }}", + "green_template": "{{ value_json.g }}", + "blue_template": "{{ value_json.b }}", + "effect_list": ["effect1", "effect2"], + }, + ), + ) + ], +) +@pytest.mark.parametrize( + ("topic", "payload1", "payload2"), + [ + ("test-topic", '{"state":"on"}', '{"state":"off"}'), + ("availability-topic", "online", "offline"), + ("json-attributes-topic", '{"attr1": "val1"}', '{"attr1": "val2"}'), + ( + "test-topic", + '{"state":"on", "brightness":50}', + '{"state":"on", "brightness":100}', + ), + ( + "test-topic", + '{"state":"on", "brightness":50,"color_temp":200}', + '{"state":"on", "brightness":50,"color_temp":1600}', + ), + ( + "test-topic", + '{"state":"on", "r":128, "g":128, "b":128}', + '{"state":"on", "r":128, "g":128, "b":255}', + ), + ( + "test-topic", + '{"state":"on", "effect":"effect1"}', + '{"state":"on", "effect":"effect2"}', + ), + ], +) +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_lock.py b/tests/components/mqtt/test_lock.py index bf7e1529a4e..e128590c907 100644 --- a/tests/components/mqtt/test_lock.py +++ b/tests/components/mqtt/test_lock.py @@ -22,6 +22,7 @@ from homeassistant.const import ( ATTR_CODE, ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, + STATE_UNKNOWN, Platform, ) from homeassistant.core import HomeAssistant @@ -50,6 +51,7 @@ from .test_common import ( 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, @@ -106,7 +108,7 @@ async def test_controlling_state_via_topic( await mqtt_mock_entry() state = hass.states.get("lock.test") - assert state.state is STATE_UNLOCKED + assert state.state is STATE_UNKNOWN assert not state.attributes.get(ATTR_ASSUMED_STATE) assert not state.attributes.get(ATTR_SUPPORTED_FEATURES) @@ -124,6 +126,7 @@ async def test_controlling_state_via_topic( (CONFIG_WITH_STATES, "closing", STATE_LOCKING), (CONFIG_WITH_STATES, "open", STATE_UNLOCKED), (CONFIG_WITH_STATES, "opening", STATE_UNLOCKING), + (CONFIG_WITH_STATES, "None", STATE_UNKNOWN), ], ) async def test_controlling_non_default_state_via_topic( @@ -136,7 +139,7 @@ async def test_controlling_non_default_state_via_topic( await mqtt_mock_entry() state = hass.states.get("lock.test") - assert state.state is STATE_UNLOCKED + assert state.state is STATE_UNKNOWN assert not state.attributes.get(ATTR_ASSUMED_STATE) async_fire_mqtt_message(hass, "state-topic", payload) @@ -184,6 +187,15 @@ async def test_controlling_non_default_state_via_topic( '{"val":"open"}', STATE_UNLOCKED, ), + ( + help_custom_config( + lock.DOMAIN, + CONFIG_WITH_STATES, + ({"value_template": "{{ value_json.val }}"},), + ), + '{"val":null}', + STATE_UNKNOWN, + ), ], ) async def test_controlling_state_via_topic_and_json_message( @@ -196,7 +208,7 @@ async def test_controlling_state_via_topic_and_json_message( await mqtt_mock_entry() state = hass.states.get("lock.test") - assert state.state is STATE_UNLOCKED + assert state.state is STATE_UNKNOWN async_fire_mqtt_message(hass, "state-topic", payload) @@ -255,7 +267,7 @@ async def test_controlling_non_default_state_via_topic_and_json_message( await mqtt_mock_entry() state = hass.states.get("lock.test") - assert state.state is STATE_UNLOCKED + assert state.state is STATE_UNKNOWN async_fire_mqtt_message(hass, "state-topic", payload) @@ -573,7 +585,7 @@ async def test_sending_mqtt_commands_pessimistic( mqtt_mock = await mqtt_mock_entry() state = hass.states.get("lock.test") - assert state.state is STATE_UNLOCKED + assert state.state is STATE_UNKNOWN assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == LockEntityFeature.OPEN # send lock command to lock @@ -1030,3 +1042,40 @@ async def test_unload_entry( await help_test_unload_config_entry_with_platform( hass, mqtt_mock_entry, domain, config ) + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + lock.DOMAIN, + CONFIG_WITH_STATES, + ( + { + "availability_topic": "availability-topic", + "json_attributes_topic": "json-attributes-topic", + }, + ), + ) + ], +) +@pytest.mark.parametrize( + ("topic", "payload1", "payload2"), + [ + ("availability-topic", "online", "offline"), + ("json-attributes-topic", '{"attr1": "val1"}', '{"attr1": "val2"}'), + ("state-topic", "closed", "open"), + ("state-topic", "closed", "opening"), + ("state-topic", "open", "closing"), + ], +) +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_number.py b/tests/components/mqtt/test_number.py index dbdd373a659..c6590c71c4d 100644 --- a/tests/components/mqtt/test_number.py +++ b/tests/components/mqtt/test_number.py @@ -31,6 +31,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, State from .test_common import ( + help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, help_test_custom_availability_payload, @@ -54,6 +55,7 @@ from .test_common import ( 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, @@ -1140,3 +1142,39 @@ 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( + number.DOMAIN, + DEFAULT_CONFIG, + ( + { + "availability_topic": "availability-topic", + "json_attributes_topic": "json-attributes-topic", + "state_topic": "test-topic", + }, + ), + ) + ], +) +@pytest.mark.parametrize( + ("topic", "payload1", "payload2"), + [ + ("test-topic", "10", "20.7"), + ("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_select.py b/tests/components/mqtt/test_select.py index f1903fa4c3c..0c18881d86e 100644 --- a/tests/components/mqtt/test_select.py +++ b/tests/components/mqtt/test_select.py @@ -25,6 +25,7 @@ from homeassistant.core import HomeAssistant, State from homeassistant.helpers.typing import ConfigType from .test_common import ( + help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, help_test_custom_availability_payload, @@ -48,6 +49,7 @@ from .test_common import ( 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, @@ -810,3 +812,39 @@ async def test_persistent_state_after_reconfig( state = hass.states.get("select.milk") assert state.state == "beer" assert state.attributes["options"] == ["beer"] + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + select.DOMAIN, + DEFAULT_CONFIG, + ( + { + "availability_topic": "availability-topic", + "json_attributes_topic": "json-attributes-topic", + "state_topic": "test-topic", + }, + ), + ) + ], +) +@pytest.mark.parametrize( + ("topic", "payload1", "payload2"), + [ + ("test-topic", "milk", "beer"), + ("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_sensor.py b/tests/components/mqtt/test_sensor.py index 043c8d539b6..bc75492a03e 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -6,6 +6,7 @@ from pathlib import Path 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 @@ from .test_common import ( 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_siren.py b/tests/components/mqtt/test_siren.py index 7c448eba85e..8a576068216 100644 --- a/tests/components/mqtt/test_siren.py +++ b/tests/components/mqtt/test_siren.py @@ -44,6 +44,7 @@ from .test_common import ( 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, @@ -257,7 +258,7 @@ async def test_controlling_state_and_attributes_with_json_message_without_templa async_fire_mqtt_message( hass, "state-topic", - '{"state":"beer off", "duration": 5, "volume_level": 0.6}', + '{"state":"beer off", "tone": "bell", "duration": 5, "volume_level": 0.6}', ) state = hass.states.get("siren.test") @@ -270,14 +271,15 @@ async def test_controlling_state_and_attributes_with_json_message_without_templa async_fire_mqtt_message( hass, "state-topic", - '{"state":"beer on", "duration": 6, "volume_level": 2 }', + '{"state":"beer on", "duration": 6, "volume_level": 2,"tone": "ping"}', ) state = hass.states.get("siren.test") assert ( - "Unable to update siren state attributes from payload '{'duration': 6, 'volume_level': 2}': value must be at most 1 for dictionary value @ data['volume_level']" + "Unable to update siren state attributes from payload '{'duration': 6, 'volume_level': 2, 'tone': 'ping'}': value must be at most 1 for dictionary value @ data['volume_level']" in caplog.text ) - assert state.state == STATE_OFF + # Only the on/of state was updated, not the attributes + assert state.state == STATE_ON assert state.attributes.get(siren.ATTR_TONE) == "bell" assert state.attributes.get(siren.ATTR_DURATION) == 5 assert state.attributes.get(siren.ATTR_VOLUME_LEVEL) == 0.6 @@ -287,7 +289,7 @@ async def test_controlling_state_and_attributes_with_json_message_without_templa "state-topic", "{}", ) - assert state.state == STATE_OFF + assert state.state == STATE_ON assert state.attributes.get(siren.ATTR_TONE) == "bell" assert state.attributes.get(siren.ATTR_DURATION) == 5 assert state.attributes.get(siren.ATTR_VOLUME_LEVEL) == 0.6 @@ -1091,3 +1093,53 @@ async def test_unload_entry( await help_test_unload_config_entry_with_platform( hass, mqtt_mock_entry, domain, config ) + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + siren.DOMAIN, + DEFAULT_CONFIG, + ( + { + "state_topic": "test-topic", + "available_tones": ["siren", "bell"], + "availability_topic": "availability-topic", + "json_attributes_topic": "json-attributes-topic", + }, + ), + ) + ], +) +@pytest.mark.parametrize( + ("topic", "payload1", "payload2"), + [ + ("availability-topic", "online", "offline"), + ("json-attributes-topic", '{"attr1": "val1"}', '{"attr1": "val2"}'), + ("test-topic", "ON", "OFF"), + ("test-topic", '{"state": "ON"}', '{"state": "OFF"}'), + ("test-topic", '{"state":"ON","tone":"siren"}', '{"state":"ON","tone":"bell"}'), + ( + "test-topic", + '{"state":"ON","tone":"siren"}', + '{"state":"OFF","tone":"siren"}', + ), + # Attriute volume_level 2 is invalid, but the state is valid and should update + ( + "test-topic", + '{"state":"ON","volume_level":0.5}', + '{"state":"OFF","volume_level":2}', + ), + ], +) +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_state_vacuum.py b/tests/components/mqtt/test_state_vacuum.py index a24884941fc..40bd5158280 100644 --- a/tests/components/mqtt/test_state_vacuum.py +++ b/tests/components/mqtt/test_state_vacuum.py @@ -58,6 +58,7 @@ from .test_common import ( 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_update_with_json_attrs_bad_json, help_test_update_with_json_attrs_not_dict, @@ -821,3 +822,40 @@ async def test_setup_manual_entity_from_yaml( await mqtt_mock_entry() platform = vacuum.DOMAIN assert hass.states.get(f"{platform}.mqtttest") + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + vacuum.DOMAIN, + DEFAULT_CONFIG, + ( + { + "availability_topic": "availability-topic", + "json_attributes_topic": "json-attributes-topic", + }, + ), + ) + ], +) +@pytest.mark.parametrize( + ("topic", "payload1", "payload2"), + [ + ("availability-topic", "online", "offline"), + ("json-attributes-topic", '{"attr1": "val1"}', '{"attr1": "val2"}'), + ("vacuum/state", '{"state": "cleaning"}', '{"state": "docked"}'), + ("vacuum/state", '{"battery_level": 71}', '{"battery_level": 60}'), + ("vacuum/state", '{"fan_speed": "max"}', '{"fan_speed": "min"}'), + ], +) +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_switch.py b/tests/components/mqtt/test_switch.py index 4471cc7dc11..32195289aab 100644 --- a/tests/components/mqtt/test_switch.py +++ b/tests/components/mqtt/test_switch.py @@ -17,6 +17,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, State from .test_common import ( + help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, help_test_custom_availability_payload, @@ -39,6 +40,7 @@ from .test_common import ( 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, @@ -762,3 +764,39 @@ async def test_unload_entry( await help_test_unload_config_entry_with_platform( hass, mqtt_mock_entry, domain, config ) + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + switch.DOMAIN, + DEFAULT_CONFIG, + ( + { + "state_topic": "test-topic", + "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_text.py b/tests/components/mqtt/test_text.py index 9e068a07824..bf6fe1b0130 100644 --- a/tests/components/mqtt/test_text.py +++ b/tests/components/mqtt/test_text.py @@ -16,6 +16,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from .test_common import ( + help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, help_test_custom_availability_payload, @@ -38,6 +39,7 @@ from .test_common import ( 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, @@ -762,3 +764,39 @@ async def test_unload_entry( await help_test_unload_config_entry_with_platform( hass, mqtt_mock_entry, domain, config ) + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + text.DOMAIN, + DEFAULT_CONFIG, + ( + { + "state_topic": "test-topic", + "availability_topic": "availability-topic", + "json_attributes_topic": "json-attributes-topic", + }, + ), + ) + ], +) +@pytest.mark.parametrize( + ("topic", "payload1", "payload2"), + [ + ("test-topic", "My original text", "Changed text"), + ("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_update.py b/tests/components/mqtt/test_update.py index 9c881352f8c..c5fe5abd8c4 100644 --- a/tests/components/mqtt/test_update.py +++ b/tests/components/mqtt/test_update.py @@ -16,6 +16,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from .test_common import ( + help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, help_test_custom_availability_payload, @@ -33,6 +34,7 @@ from .test_common import ( 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, @@ -47,7 +49,7 @@ DEFAULT_CONFIG = { update.DOMAIN: { "name": "test", "state_topic": "test-topic", - "latest_version_topic": "test-topic", + "latest_version_topic": "latest-version-topic", "command_topic": "test-topic", "payload_install": "install", } @@ -730,3 +732,53 @@ async def test_reloadable( domain = update.DOMAIN config = DEFAULT_CONFIG await help_test_reloadable(hass, mqtt_client_mock, domain, config) + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + update.DOMAIN, + DEFAULT_CONFIG, + ( + { + "availability_topic": "availability-topic", + "json_attributes_topic": "json-attributes-topic", + }, + ), + ) + ], +) +@pytest.mark.parametrize( + ("topic", "payload1", "payload2"), + [ + ("latest-version-topic", "1.1", "1.2"), + ("test-topic", "1.1", "1.2"), + ("test-topic", '{"installed_version": "1.1"}', '{"installed_version": "1.2"}'), + ("test-topic", '{"latest_version": "1.1"}', '{"latest_version": "1.2"}'), + ("test-topic", '{"title": "Update"}', '{"title": "Patch"}'), + ("test-topic", '{"release_summary": "bla1"}', '{"release_summary": "bla2"}'), + ( + "test-topic", + '{"release_url": "https://example.com/update?r=1"}', + '{"release_url": "https://example.com/update?r=2"}', + ), + ( + "test-topic", + '{"entity_picture": "https://example.com/icon1.png"}', + '{"entity_picture": "https://example.com/icon2.png"}', + ), + ("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 e93a5e376bb..941072bc224 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.common import MockConfigEntry 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/mqtt/test_water_heater.py b/tests/components/mqtt/test_water_heater.py index 245af5c6918..60c3af63bf4 100644 --- a/tests/components/mqtt/test_water_heater.py +++ b/tests/components/mqtt/test_water_heater.py @@ -52,6 +52,7 @@ from .test_common import ( 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, @@ -1220,3 +1221,43 @@ async def test_unload_entry( await help_test_unload_config_entry_with_platform( hass, mqtt_mock_entry, domain, config ) + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + water_heater.DOMAIN, + DEFAULT_CONFIG, + ( + { + "availability_topic": "availability-topic", + "json_attributes_topic": "json-attributes-topic", + "mode_state_topic": "mode-state-topic", + "current_temperature_topic": "current-temperature-topic", + "temperature_state_topic": "temperature-state-topic", + }, + ), + ) + ], +) +@pytest.mark.parametrize( + ("topic", "payload1", "payload2"), + [ + ("availability-topic", "online", "offline"), + ("json-attributes-topic", '{"attr1": "val1"}', '{"attr1": "val2"}'), + ("mode-state-topic", "gas", "electric"), + ("current-temperature-topic", "18.0", "18.1"), + ("temperature-state-topic", "18", "19"), + ], +) +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/mysensors/conftest.py b/tests/components/mysensors/conftest.py index e7c0a3c5a7b..883a94ea02e 100644 --- a/tests/components/mysensors/conftest.py +++ b/tests/components/mysensors/conftest.py @@ -470,3 +470,19 @@ def text_node(gateway_nodes: dict[int, Sensor], text_node_state: dict) -> Sensor nodes = update_gateway_nodes(gateway_nodes, text_node_state) node = nodes[1] return node + + +@pytest.fixture(name="battery_sensor_state", scope="session") +def battery_sensor_state_fixture() -> dict: + """Load the battery sensor state.""" + return load_nodes_state("battery_sensor_state.json") + + +@pytest.fixture +def battery_sensor( + gateway_nodes: dict[int, Sensor], battery_sensor_state: dict +) -> Sensor: + """Load the battery sensor.""" + nodes = update_gateway_nodes(gateway_nodes, deepcopy(battery_sensor_state)) + node = nodes[1] + return node diff --git a/tests/components/mysensors/fixtures/battery_sensor_state.json b/tests/components/mysensors/fixtures/battery_sensor_state.json new file mode 100644 index 00000000000..fc89237ed97 --- /dev/null +++ b/tests/components/mysensors/fixtures/battery_sensor_state.json @@ -0,0 +1,12 @@ +{ + "1": { + "sensor_id": 1, + "children": {}, + "type": 17, + "sketch_name": "Battery Sensor", + "sketch_version": "1.0", + "battery_level": 42, + "protocol_version": "2.3.2", + "heartbeat": 0 + } +} diff --git a/tests/components/mysensors/test_sensor.py b/tests/components/mysensors/test_sensor.py index 12a47896326..17301e4b212 100644 --- a/tests/components/mysensors/test_sensor.py +++ b/tests/components/mysensors/test_sensor.py @@ -77,6 +77,25 @@ async def test_ir_transceiver( assert state.state == "new_code" +async def test_battery_entity( + hass: HomeAssistant, + battery_sensor: Sensor, + receive_message: Callable[[str], None], +) -> None: + """Test sensor with battery level reporting.""" + battery_entity_id = "sensor.battery_sensor_1_battery" + state = hass.states.get(battery_entity_id) + assert state + assert state.state == "42" + + receive_message("1;255;3;0;0;84\n") + await hass.async_block_till_done() + + state = hass.states.get(battery_entity_id) + assert state + assert state.state == "84" + + async def test_power_sensor( hass: HomeAssistant, power_sensor: Sensor, diff --git a/tests/components/nam/test_config_flow.py b/tests/components/nam/test_config_flow.py index 78a96e148ce..a8f1245d9d6 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 homeassistant.core import HomeAssistant 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 9a7f4a2bc50..2fce4e55bbc 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_diagnostics.py b/tests/components/nest/test_diagnostics.py index 191253a2a9a..c9b5f2f0de1 100644 --- a/tests/components/nest/test_diagnostics.py +++ b/tests/components/nest/test_diagnostics.py @@ -60,7 +60,7 @@ CAMERA_API_DATA = { "type": "sdm.devices.types.CAMERA", "traits": { "sdm.devices.traits.CameraLiveStream": { - "videoCodecs": "H264", + "videoCodecs": ["H264"], "supportedProtocols": ["RTSP"], }, }, @@ -71,7 +71,7 @@ CAMERA_DIAGNOSTIC_DATA = { "name": "**REDACTED**", "traits": { "sdm.devices.traits.CameraLiveStream": { - "videoCodecs": "H264", + "videoCodecs": ["H264"], "supportedProtocols": ["RTSP"], }, }, diff --git a/tests/components/nest/test_media_source.py b/tests/components/nest/test_media_source.py index 6c827e76163..a1c62799585 100644 --- a/tests/components/nest/test_media_source.py +++ b/tests/components/nest/test_media_source.py @@ -231,7 +231,9 @@ def create_battery_event_data( ( "sdm.devices.types.THERMOSTAT", { - "sdm.devices.traits.Temperature": {}, + "sdm.devices.traits.Temperature": { + "ambientTemperatureCelsius": 22.0, + }, }, ) ], 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/snapshots/test_diagnostics.ambr b/tests/components/netatmo/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..228fc7563e0 --- /dev/null +++ b/tests/components/netatmo/snapshots/test_diagnostics.ambr @@ -0,0 +1,620 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'data': dict({ + 'account': dict({ + 'errors': list([ + ]), + 'homes': list([ + dict({ + 'altitude': 112, + 'coordinates': '**REDACTED**', + 'country': 'DE', + 'id': '91763b24c43d3e344f424e8b', + 'modules': list([ + dict({ + 'id': '12:34:56:00:fa:d0', + 'modules_bridged': list([ + '12:34:56:00:01:ae', + '12:34:56:03:a0:ac', + '12:34:56:03:a5:54', + ]), + 'name': '**REDACTED**', + 'setup_date': 1494963356, + 'type': 'NAPlug', + }), + dict({ + 'bridge': '12:34:56:00:fa:d0', + 'id': '12:34:56:00:01:ae', + 'name': '**REDACTED**', + 'room_id': '2746182631', + 'setup_date': 1494963356, + 'type': 'NATherm1', + }), + dict({ + 'bridge': '12:34:56:00:fa:d0', + 'id': '12:34:56:03:a5:54', + 'name': '**REDACTED**', + 'room_id': '2833524037', + 'setup_date': 1554549767, + 'type': 'NRV', + }), + dict({ + 'bridge': '12:34:56:00:fa:d0', + 'id': '12:34:56:03:a0:ac', + 'name': '**REDACTED**', + 'room_id': '2940411577', + 'setup_date': 1554554444, + 'type': 'NRV', + }), + dict({ + 'id': '12:34:56:00:f1:62', + 'modules_bridged': list([ + '12:34:56:00:86:99', + '12:34:56:00:e3:9b', + ]), + 'name': '**REDACTED**', + 'room_id': '3688132631', + 'setup_date': 1544828430, + 'type': 'NACamera', + }), + dict({ + 'customer_id': '1000010', + 'hk_device_id': '123456007df1', + 'id': '12:34:56:10:f1:66', + 'name': '**REDACTED**', + 'network_lock': False, + 'quick_display_zone': 62, + 'reachable': True, + 'room_id': '3688132631', + 'setup_date': 1602691361, + 'type': 'NDB', + }), + dict({ + 'customer_id': 'A00010', + 'id': '12:34:56:10:b9:0e', + 'name': '**REDACTED**', + 'network_lock': False, + 'reachable': True, + 'setup_date': 1509290599, + 'type': 'NOC', + 'use_pincode': False, + }), + dict({ + 'capabilities': list([ + dict({ + 'available': True, + 'name': '**REDACTED**', + }), + ]), + 'hk_device_id': '12:34:56:20:d0:c5', + 'id': '12:34:56:20:f5:44', + 'max_modules_nb': 21, + 'modules_bridged': list([ + '12:34:56:20:f5:8c', + ]), + 'name': '**REDACTED**', + 'reachable': True, + 'room_id': '222452125', + 'setup_date': 1607443936, + 'type': 'OTH', + }), + dict({ + 'bridge': '12:34:56:20:f5:44', + 'id': '12:34:56:20:f5:8c', + 'name': '**REDACTED**', + 'room_id': '222452125', + 'setup_date': 1607443939, + 'type': 'OTM', + }), + dict({ + 'id': '12:34:56:30:d5:d4', + 'modules_bridged': list([ + '0009999992', + ]), + 'name': '**REDACTED**', + 'room_id': '222452125', + 'setup_date': 1562262465, + 'type': 'NBG', + }), + dict({ + 'bridge': '12:34:56:30:d5:d4', + 'id': '0009999992', + 'name': '**REDACTED**', + 'room_id': '3688132631', + 'setup_date': 1578551339, + 'type': 'NBR', + }), + dict({ + 'alarm_config': dict({ + 'default_alarm': list([ + dict({ + 'db_alarm_number': 0, + }), + dict({ + 'db_alarm_number': 1, + }), + dict({ + 'db_alarm_number': 2, + }), + dict({ + 'db_alarm_number': 6, + }), + dict({ + 'db_alarm_number': 4, + }), + dict({ + 'db_alarm_number': 5, + }), + dict({ + 'db_alarm_number': 7, + }), + dict({ + 'db_alarm_number': 22, + }), + ]), + 'personnalized': list([ + dict({ + 'data_type': 1, + 'db_alarm_number': 8, + 'direction': 0, + 'threshold': 20, + }), + dict({ + 'data_type': 1, + 'db_alarm_number': 9, + 'direction': 1, + 'threshold': 17, + }), + dict({ + 'data_type': 4, + 'db_alarm_number': 16, + 'direction': 0, + 'threshold': 65, + }), + dict({ + 'data_type': 8, + 'db_alarm_number': 22, + 'direction': 0, + 'threshold': 19, + }), + ]), + }), + 'customer_id': 'C00016', + 'hardware_version': 251, + 'id': '12:34:56:80:bb:26', + 'module_offset': dict({ + '03:00:00:03:1b:0e': dict({ + 'a': 0, + }), + '12:34:56:80:bb:26': dict({ + 'a': 0.1, + }), + }), + 'modules_bridged': list([ + '12:34:56:80:44:92', + '12:34:56:80:7e:18', + '12:34:56:80:1c:42', + '12:34:56:80:c1:ea', + ]), + 'name': '**REDACTED**', + 'public_ext_counter': 0, + 'public_ext_data': False, + 'reachable': True, + 'room_id': '4122897288', + 'setup_date': 1419453350, + 'type': 'NAMain', + }), + dict({ + 'bridge': '12:34:56:80:bb:26', + 'id': '12:34:56:80:1c:42', + 'name': '**REDACTED**', + 'setup_date': 1448565785, + 'type': 'NAModule1', + }), + dict({ + 'bridge': '12:34:56:80:bb:26', + 'id': '12:34:56:80:c1:ea', + 'name': '**REDACTED**', + 'setup_date': 1591770206, + 'type': 'NAModule3', + }), + dict({ + 'bridge': '12:34:56:80:bb:26', + 'id': '12:34:56:80:44:92', + 'name': '**REDACTED**', + 'setup_date': 1484997703, + 'type': 'NAModule4', + }), + dict({ + 'bridge': '12:34:56:80:bb:26', + 'id': '12:34:56:80:7e:18', + 'name': '**REDACTED**', + 'setup_date': 1543579864, + 'type': 'NAModule4', + }), + dict({ + 'bridge': '12:34:56:80:bb:26', + 'id': '12:34:56:03:1b:e4', + 'name': '**REDACTED**', + 'setup_date': 1543579864, + 'type': 'NAModule2', + }), + dict({ + 'id': '12:34:56:80:60:40', + 'modules_bridged': list([ + '12:34:56:80:00:12:ac:f2', + '12:34:56:80:00:c3:69:3c', + '12:34:56:00:00:a1:4c:da', + '12:34:56:00:01:01:01:a1', + '00:11:22:33:00:11:45:fe', + ]), + 'name': '**REDACTED**', + 'room_id': '1310352496', + 'setup_date': 1641841257, + 'type': 'NLG', + }), + dict({ + 'bridge': '12:34:56:80:60:40', + 'id': '12:34:56:80:00:12:ac:f2', + 'name': '**REDACTED**', + 'room_id': '1310352496', + 'setup_date': 1641841262, + 'type': 'NLP', + }), + dict({ + 'bridge': '12:34:56:80:60:40', + 'id': '12:34:56:80:00:c3:69:3c', + 'name': '**REDACTED**', + 'setup_date': 1641841262, + 'type': 'NLT', + }), + dict({ + 'bridge': '12:34:56:00:f1:62', + 'category': 'window', + 'id': '12:34:56:00:86:99', + 'name': '**REDACTED**', + 'setup_date': 1581177375, + 'type': 'NACamDoorTag', + }), + dict({ + 'bridge': '12:34:56:00:f1:62', + 'id': '12:34:56:00:e3:9b', + 'name': '**REDACTED**', + 'setup_date': 1620479901, + 'type': 'NIS', + }), + dict({ + 'id': '12:34:56:00:16:0e', + 'modules_bridged': list([ + '12:34:56:00:16:0e#0', + '12:34:56:00:16:0e#1', + '12:34:56:00:16:0e#2', + '12:34:56:00:16:0e#3', + '12:34:56:00:16:0e#4', + '12:34:56:00:16:0e#5', + '12:34:56:00:16:0e#6', + '12:34:56:00:16:0e#7', + '12:34:56:00:16:0e#8', + ]), + 'name': '**REDACTED**', + 'room_id': '100007519', + 'setup_date': 1644496884, + 'type': 'NLE', + }), + dict({ + 'bridge': '12:34:56:00:16:0e', + 'id': '12:34:56:00:16:0e#0', + 'name': '**REDACTED**', + 'room_id': '100007519', + 'setup_date': 1644496886, + 'type': 'NLE', + }), + dict({ + 'bridge': '12:34:56:00:16:0e', + 'id': '12:34:56:00:16:0e#1', + 'name': '**REDACTED**', + 'room_id': '100007519', + 'setup_date': 1644496886, + 'type': 'NLE', + }), + dict({ + 'bridge': '12:34:56:00:16:0e', + 'id': '12:34:56:00:16:0e#2', + 'name': '**REDACTED**', + 'room_id': '100007519', + 'setup_date': 1644496886, + 'type': 'NLE', + }), + dict({ + 'bridge': '12:34:56:00:16:0e', + 'id': '12:34:56:00:16:0e#3', + 'name': '**REDACTED**', + 'room_id': '100007519', + 'setup_date': 1644496886, + 'type': 'NLE', + }), + dict({ + 'bridge': '12:34:56:00:16:0e', + 'id': '12:34:56:00:16:0e#4', + 'name': '**REDACTED**', + 'room_id': '100007519', + 'setup_date': 1644496886, + 'type': 'NLE', + }), + dict({ + 'bridge': '12:34:56:00:16:0e', + 'id': '12:34:56:00:16:0e#5', + 'name': '**REDACTED**', + 'room_id': '100007519', + 'setup_date': 1644496886, + 'type': 'NLE', + }), + dict({ + 'bridge': '12:34:56:00:16:0e', + 'id': '12:34:56:00:16:0e#6', + 'name': '**REDACTED**', + 'room_id': '100007519', + 'setup_date': 1644496886, + 'type': 'NLE', + }), + dict({ + 'bridge': '12:34:56:00:16:0e', + 'id': '12:34:56:00:16:0e#7', + 'name': '**REDACTED**', + 'room_id': '100007519', + 'setup_date': 1644496886, + 'type': 'NLE', + }), + dict({ + 'bridge': '12:34:56:00:16:0e', + 'id': '12:34:56:00:16:0e#8', + 'name': '**REDACTED**', + 'room_id': '100007519', + 'setup_date': 1644496886, + 'type': 'NLE', + }), + dict({ + 'bridge': '12:34:56:80:60:40', + 'id': '12:34:56:00:00:a1:4c:da', + 'name': '**REDACTED**', + 'room_id': '100008999', + 'setup_date': 1638376602, + 'type': 'NLPC', + }), + dict({ + 'id': '10:20:30:bd:b8:1e', + 'name': '**REDACTED**', + 'room_id': '1002003001', + 'setup_date': 1638022197, + 'type': 'BNS', + }), + dict({ + 'bridge': '12:34:56:80:60:40', + 'brightness': 63, + 'firmware_revision': 57, + 'id': '00:11:22:33:00:11:45:fe', + 'last_seen': 1657086939, + 'on': False, + 'power': 0, + 'reachable': True, + 'type': 'NLF', + }), + dict({ + 'bridge': '12:34:56:80:60:40', + 'id': '12:34:56:00:01:01:01:a1', + 'name': '**REDACTED**', + 'room_id': '1002003001', + 'setup_date': 1598367404, + 'type': 'NLFN', + }), + ]), + 'name': '**REDACTED**', + 'persons': list([ + dict({ + 'id': '91827374-7e04-5298-83ad-a0cb8372dff1', + 'pseudo': '**REDACTED**', + 'url': '**REDACTED**', + }), + dict({ + 'id': '91827375-7e04-5298-83ae-a0cb8372dff2', + 'pseudo': '**REDACTED**', + 'url': '**REDACTED**', + }), + dict({ + 'id': '91827376-7e04-5298-83af-a0cb8372dff3', + 'pseudo': '**REDACTED**', + 'url': '**REDACTED**', + }), + ]), + 'rooms': list([ + dict({ + 'id': '2746182631', + 'module_ids': list([ + '12:34:56:00:01:ae', + ]), + 'name': '**REDACTED**', + 'type': 'livingroom', + }), + dict({ + 'id': '3688132631', + 'module_ids': list([ + '12:34:56:00:f1:62', + '12:34:56:10:f1:66', + '12:34:56:00:e3:9b', + '0009999992', + ]), + 'name': '**REDACTED**', + 'type': 'custom', + }), + dict({ + 'id': '2833524037', + 'module_ids': list([ + '12:34:56:03:a5:54', + ]), + 'name': '**REDACTED**', + 'type': 'lobby', + }), + dict({ + 'id': '2940411577', + 'module_ids': list([ + '12:34:56:03:a0:ac', + ]), + 'name': '**REDACTED**', + 'type': 'kitchen', + }), + dict({ + 'id': '222452125', + 'module_ids': list([ + '12:34:56:20:f5:44', + '12:34:56:20:f5:8c', + ]), + 'modules': list([ + '12:34:56:20:f5:44', + '12:34:56:20:f5:8c', + ]), + 'name': '**REDACTED**', + 'therm_relay': '12:34:56:20:f5:44', + 'true_temperature_available': True, + 'type': 'electrical_cabinet', + }), + dict({ + 'id': '100007519', + 'module_ids': list([ + '12:34:56:00:16:0e', + '12:34:56:00:16:0e#0', + '12:34:56:00:16:0e#1', + '12:34:56:00:16:0e#2', + '12:34:56:00:16:0e#3', + '12:34:56:00:16:0e#4', + '12:34:56:00:16:0e#5', + '12:34:56:00:16:0e#6', + '12:34:56:00:16:0e#7', + '12:34:56:00:16:0e#8', + ]), + 'name': '**REDACTED**', + 'type': 'electrical_cabinet', + }), + dict({ + 'id': '1002003001', + 'module_ids': list([ + '10:20:30:bd:b8:1e', + ]), + 'name': '**REDACTED**', + 'type': 'corridor', + }), + dict({ + 'id': '100007520', + 'module_ids': list([ + '00:11:22:33:00:11:45:fe', + ]), + 'name': '**REDACTED**', + 'type': 'toilets', + }), + ]), + 'schedules': list([ + dict({ + 'away_temp': 14, + 'hg_temp': 7, + 'id': '591b54a2764ff4d50d8b5795', + 'name': '**REDACTED**', + 'selected': True, + 'timetable': '**REDACTED**', + 'type': 'therm', + 'zones': '**REDACTED**', + }), + dict({ + 'away_temp': 14, + 'hg_temp': 7, + 'id': 'b1b54a2f45795764f59d50d8', + 'name': '**REDACTED**', + 'timetable': '**REDACTED**', + 'type': 'therm', + 'zones': '**REDACTED**', + }), + ]), + 'therm_mode': 'schedule', + 'therm_setpoint_default_duration': 120, + 'timezone': 'Europe/Berlin', + }), + dict({ + 'altitude': 112, + 'coordinates': '**REDACTED**', + 'country': 'DE', + 'id': '91763b24c43d3e344f424e8c', + 'therm_mode': 'schedule', + 'therm_setpoint_default_duration': 180, + 'timezone': 'Europe/Berlin', + }), + ]), + }), + }), + 'info': dict({ + 'data': dict({ + 'auth_implementation': 'cloud', + 'token': dict({ + 'access_token': '**REDACTED**', + 'expires_in': 60, + 'refresh_token': '**REDACTED**', + 'scope': list([ + 'access_camera', + 'access_doorbell', + 'access_presence', + 'read_bubendorff', + 'read_camera', + 'read_carbonmonoxidedetector', + 'read_doorbell', + 'read_homecoach', + 'read_magellan', + 'read_mx', + 'read_presence', + 'read_smarther', + 'read_smokedetector', + 'read_station', + 'read_thermostat', + 'write_bubendorff', + 'write_camera', + 'write_magellan', + 'write_mx', + 'write_presence', + 'write_smarther', + 'write_thermostat', + ]), + 'type': 'Bearer', + }), + 'webhook_id': '**REDACTED**', + }), + 'disabled_by': None, + 'domain': 'netatmo', + 'options': dict({ + 'weather_areas': dict({ + 'Home avg': dict({ + 'area_name': 'Home avg', + 'lat_ne': '**REDACTED**', + 'lat_sw': '**REDACTED**', + 'lon_ne': '**REDACTED**', + 'lon_sw': '**REDACTED**', + 'mode': 'avg', + 'show_on_map': False, + }), + 'Home max': dict({ + 'area_name': 'Home max', + 'lat_ne': '**REDACTED**', + 'lat_sw': '**REDACTED**', + 'lon_ne': '**REDACTED**', + 'lon_sw': '**REDACTED**', + 'mode': 'max', + 'show_on_map': True, + }), + }), + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'Mock Title', + 'unique_id': 'netatmo', + 'version': 1, + 'webhook_registered': False, + }), + }) +# --- diff --git a/tests/components/netatmo/test_config_flow.py b/tests/components/netatmo/test_config_flow.py index a89fff13cdd..56d319b1631 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/netatmo/test_diagnostics.py b/tests/components/netatmo/test_diagnostics.py index 6c0c489be3d..0ece935abcb 100644 --- a/tests/components/netatmo/test_diagnostics.py +++ b/tests/components/netatmo/test_diagnostics.py @@ -1,7 +1,9 @@ """Test the Netatmo diagnostics.""" from unittest.mock import AsyncMock, patch -from homeassistant.components.diagnostics import REDACTED +from syrupy import SnapshotAssertion +from syrupy.filters import paths + from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -12,7 +14,10 @@ from tests.typing import ClientSessionGenerator async def test_entry_diagnostics( - hass: HomeAssistant, hass_client: ClientSessionGenerator, config_entry + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, + config_entry, ) -> None: """Test config entry diagnostics.""" with patch( @@ -29,79 +34,6 @@ async def test_entry_diagnostics( await hass.async_block_till_done() - result = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) - - # ignore for tests - result["info"]["data"]["token"].pop("expires_at") - result["info"].pop("entry_id") - - assert result["info"] == { - "data": { - "auth_implementation": "cloud", - "token": { - "access_token": REDACTED, - "expires_in": 60, - "refresh_token": REDACTED, - "scope": [ - "access_camera", - "access_doorbell", - "access_presence", - "read_bubendorff", - "read_camera", - "read_carbonmonoxidedetector", - "read_doorbell", - "read_homecoach", - "read_magellan", - "read_mx", - "read_presence", - "read_smarther", - "read_smokedetector", - "read_station", - "read_thermostat", - "write_bubendorff", - "write_camera", - "write_magellan", - "write_mx", - "write_presence", - "write_smarther", - "write_thermostat", - ], - "type": "Bearer", - }, - "webhook_id": REDACTED, - }, - "disabled_by": None, - "domain": "netatmo", - "options": { - "weather_areas": { - "Home avg": { - "area_name": "Home avg", - "lat_ne": REDACTED, - "lat_sw": REDACTED, - "lon_ne": REDACTED, - "lon_sw": REDACTED, - "mode": "avg", - "show_on_map": False, - }, - "Home max": { - "area_name": "Home max", - "lat_ne": REDACTED, - "lat_sw": REDACTED, - "lon_ne": REDACTED, - "lon_sw": REDACTED, - "mode": "max", - "show_on_map": True, - }, - } - }, - "pref_disable_new_entities": False, - "pref_disable_polling": False, - "source": "user", - "title": "Mock Title", - "unique_id": "netatmo", - "version": 1, - "webhook_registered": False, - } - - for home in result["data"]["account"]["homes"]: - assert home["coordinates"] == REDACTED + assert await get_diagnostics_for_config_entry( + hass, hass_client, config_entry + ) == snapshot(exclude=paths("info.data.token.expires_at", "info.entry_id")) diff --git a/tests/components/nexia/snapshots/test_diagnostics.ambr b/tests/components/nexia/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..f7a7df8854b --- /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 f58574098cc..9f8f7f05a8d 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 @@ from tests.typing import ClientSessionGenerator 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 00000000000..a38f3fd850e --- /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 00000000000..9f427757183 --- /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 4884d04d3aa..071dd95fe7b 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_STOP = "5650" 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" -CONFIG_BASIC = { - "sensor": { - "platform": "nextbus", - "agency": VALID_AGENCY, - "route": VALID_ROUTE, - "stop": VALID_STOP, - } +PLATFORM_CONFIG = { + sensor.DOMAIN: { + "platform": DOMAIN, + CONF_AGENCY: VALID_AGENCY, + CONF_ROUTE: VALID_ROUTE, + CONF_STOP: VALID_STOP, + }, } -CONFIG_INVALID_MISSING = {"sensor": {"platform": "nextbus"}} + +CONFIG_BASIC = { + DOMAIN: { + CONF_AGENCY: VALID_AGENCY, + CONF_ROUTE: VALID_ROUTE, + CONF_STOP: VALID_STOP, + } +} 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 @@ BASIC_RESULTS = { } -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) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is expected_state + + return config_entry + + +async def test_legacy_yaml_setup( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, +) -> None: + """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 async def test_valid_config( - hass: HomeAssistant, mock_nextbus, mock_nextbus_lists + 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_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) - - -async def test_validate_tags( - hass: HomeAssistant, mock_nextbus, mock_nextbus_lists -) -> 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 - ) - - # 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_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 00000000000..798171464e6 --- /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/number/test_init.py b/tests/components/number/test_init.py index 23758fe345d..3f612c421c8 100644 --- a/tests/components/number/test_init.py +++ b/tests/components/number/test_init.py @@ -901,3 +901,13 @@ async def test_name(hass: HomeAssistant) -> None: "mode": NumberMode.AUTO, "step": 1.0, } + + +def test_device_class_units(hass: HomeAssistant) -> None: + """Test all numeric device classes have unit.""" + # DEVICE_CLASS_UNITS should include all device classes except: + # - NumberDeviceClass.MONETARY + # - Device classes enumerated in NON_NUMERIC_DEVICE_CLASSES + assert set(NUMBER_DEVICE_CLASS_UNITS) == set( + NumberDeviceClass + ) - NON_NUMERIC_DEVICE_CLASSES - {NumberDeviceClass.MONETARY} diff --git a/tests/components/nut/test_config_flow.py b/tests/components/nut/test_config_flow.py index 8ce4916fc66..46bc2bc2a64 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 c078a6523bc..e26be8b9880 100644 --- a/tests/components/nzbget/test_config_flow.py +++ b/tests/components/nzbget/test_config_flow.py @@ -5,7 +5,7 @@ from pynzbgetapi import NZBGetAPIException 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 f2423f6da27..e3cf45708fa 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 89b0b7e8427..a9d950a3a66 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_GATEWAY_RESPONSE = [Mock(id=TEST_GATEWAY_ID)] 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/plex/conftest.py b/tests/components/plex/conftest.py index e4bf61ccd94..78a3b7387ea 100644 --- a/tests/components/plex/conftest.py +++ b/tests/components/plex/conftest.py @@ -232,6 +232,12 @@ def player_plexweb_resources_fixture(): return load_fixture("plex/player_plexweb_resources.xml") +@pytest.fixture(name="player_plexhtpc_resources", scope="session") +def player_plexhtpc_resources_fixture(): + """Load resources payload for a Plex HTPC player and return it.""" + return load_fixture("plex/player_plexhtpc_resources.xml") + + @pytest.fixture(name="playlists", scope="session") def playlists_fixture(): """Load payload for all playlists and return it.""" @@ -450,8 +456,8 @@ def mock_plex_calls( """Mock Plex API calls.""" requests_mock.get("https://plex.tv/api/users/", text=plextv_shared_users) requests_mock.get("https://plex.tv/api/invites/requested", text=empty_payload) - requests_mock.get("https://plex.tv/users/account", text=plextv_account) - requests_mock.get("https://plex.tv/api/resources", text=plextv_resources) + requests_mock.get("https://plex.tv/api/v2/user", text=plextv_account) + requests_mock.get("https://plex.tv/api/v2/resources", text=plextv_resources) url = plex_server_url(entry) diff --git a/tests/components/plex/fixtures/player_plexhtpc_resources.xml b/tests/components/plex/fixtures/player_plexhtpc_resources.xml new file mode 100644 index 00000000000..6cc9cc0afbd --- /dev/null +++ b/tests/components/plex/fixtures/player_plexhtpc_resources.xml @@ -0,0 +1,3 @@ + + + diff --git a/tests/components/plex/fixtures/plextv_account.xml b/tests/components/plex/fixtures/plextv_account.xml index 32d6eec7c2d..b47896de577 100644 --- a/tests/components/plex/fixtures/plextv_account.xml +++ b/tests/components/plex/fixtures/plextv_account.xml @@ -1,15 +1,18 @@ - - - + + + + + + + + + - - - - testuser - testuser@email.com - 2000-01-01 12:34:56 UTC - faketoken + + + + diff --git a/tests/components/plex/fixtures/plextv_resources_one_server.xml b/tests/components/plex/fixtures/plextv_resources_one_server.xml index ff2e458ff24..75b7e54b7e6 100644 --- a/tests/components/plex/fixtures/plextv_resources_one_server.xml +++ b/tests/components/plex/fixtures/plextv_resources_one_server.xml @@ -1,18 +1,22 @@ - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/components/plex/fixtures/plextv_resources_two_servers.xml b/tests/components/plex/fixtures/plextv_resources_two_servers.xml index 7da5df4c1df..f14b55fe161 100644 --- a/tests/components/plex/fixtures/plextv_resources_two_servers.xml +++ b/tests/components/plex/fixtures/plextv_resources_two_servers.xml @@ -1,21 +1,27 @@ - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/components/plex/test_config_flow.py b/tests/components/plex/test_config_flow.py index beb454e2e9c..235596715f4 100644 --- a/tests/components/plex/test_config_flow.py +++ b/tests/components/plex/test_config_flow.py @@ -143,7 +143,7 @@ async def test_no_servers_found( current_request_with_host: None, ) -> None: """Test when no servers are on an account.""" - requests_mock.get("https://plex.tv/api/resources", text=empty_payload) + requests_mock.get("https://plex.tv/api/v2/resources", text=empty_payload) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} @@ -225,7 +225,7 @@ async def test_multiple_servers_with_selection( assert result["step_id"] == "user" requests_mock.get( - "https://plex.tv/api/resources", + "https://plex.tv/api/v2/resources", text=plextv_resources_two_servers, ) with patch("plexauth.PlexAuth.initiate_auth"), patch( @@ -289,7 +289,7 @@ async def test_adding_last_unconfigured_server( assert result["step_id"] == "user" requests_mock.get( - "https://plex.tv/api/resources", + "https://plex.tv/api/v2/resources", text=plextv_resources_two_servers, ) @@ -346,9 +346,9 @@ async def test_all_available_servers_configured( assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" - requests_mock.get("https://plex.tv/users/account", text=plextv_account) + requests_mock.get("https://plex.tv/api/v2/user", text=plextv_account) requests_mock.get( - "https://plex.tv/api/resources", + "https://plex.tv/api/v2/resources", text=plextv_resources_two_servers, ) @@ -776,7 +776,7 @@ async def test_reauth_multiple_servers_available( ) -> None: """Test setup and reauthorization of a Plex token when multiple servers are available.""" requests_mock.get( - "https://plex.tv/api/resources", + "https://plex.tv/api/v2/resources", text=plextv_resources_two_servers, ) diff --git a/tests/components/plex/test_init.py b/tests/components/plex/test_init.py index bc43a1e0d89..6e1043b5c52 100644 --- a/tests/components/plex/test_init.py +++ b/tests/components/plex/test_init.py @@ -231,7 +231,7 @@ async def test_setup_when_certificate_changed( # Test with account failure requests_mock.get( - "https://plex.tv/users/account", status_code=HTTPStatus.UNAUTHORIZED + "https://plex.tv/api/v2/user", status_code=HTTPStatus.UNAUTHORIZED ) old_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(old_entry.entry_id) is False @@ -241,8 +241,8 @@ async def test_setup_when_certificate_changed( await hass.config_entries.async_unload(old_entry.entry_id) # Test with no servers found - requests_mock.get("https://plex.tv/users/account", text=plextv_account) - requests_mock.get("https://plex.tv/api/resources", text=empty_payload) + requests_mock.get("https://plex.tv/api/v2/user", text=plextv_account) + requests_mock.get("https://plex.tv/api/v2/resources", text=empty_payload) assert await hass.config_entries.async_setup(old_entry.entry_id) is False await hass.async_block_till_done() @@ -252,7 +252,7 @@ async def test_setup_when_certificate_changed( # Test with success new_url = PLEX_DIRECT_URL - requests_mock.get("https://plex.tv/api/resources", text=plextv_resources) + requests_mock.get("https://plex.tv/api/v2/resources", text=plextv_resources) for resource_url in [new_url, "http://1.2.3.4:32400"]: requests_mock.get(resource_url, text=plex_server_default) requests_mock.get(f"{new_url}/accounts", text=plex_server_accounts) @@ -287,7 +287,7 @@ async def test_bad_token_with_tokenless_server( ) -> None: """Test setup with a bad token and a server with token auth disabled.""" requests_mock.get( - "https://plex.tv/users/account", status_code=HTTPStatus.UNAUTHORIZED + "https://plex.tv/api/v2/user", status_code=HTTPStatus.UNAUTHORIZED ) await setup_plex_server() diff --git a/tests/components/plex/test_media_players.py b/tests/components/plex/test_media_players.py index 27fea36e3b0..e9efc945f71 100644 --- a/tests/components/plex/test_media_players.py +++ b/tests/components/plex/test_media_players.py @@ -12,10 +12,10 @@ async def test_plex_tv_clients( entry, setup_plex_server, requests_mock: requests_mock.Mocker, - player_plexweb_resources, + player_plexhtpc_resources, ) -> None: """Test getting Plex clients from plex.tv.""" - requests_mock.get("/resources", text=player_plexweb_resources) + requests_mock.get("/resources", text=player_plexhtpc_resources) with patch("plexapi.myplex.MyPlexResource.connect", side_effect=NotFound): await setup_plex_server() diff --git a/tests/components/plex/test_media_search.py b/tests/components/plex/test_media_search.py index 0cc94134f1c..21b50724786 100644 --- a/tests/components/plex/test_media_search.py +++ b/tests/components/plex/test_media_search.py @@ -70,7 +70,10 @@ async def test_media_lookups( ) assert "Library 'Not a Library' not found in" in str(excinfo.value) - with patch("plexapi.library.LibrarySection.search") as search: + with patch( + "plexapi.library.LibrarySection.search", + __qualname__="search", + ) as search: await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, @@ -261,7 +264,11 @@ async def test_media_lookups( with pytest.raises(MediaNotFound) as excinfo: payload = '{"library_name": "Movies", "title": "Not a Movie"}' - with patch("plexapi.library.LibrarySection.search", side_effect=BadRequest): + with patch( + "plexapi.library.LibrarySection.search", + side_effect=BadRequest, + __qualname__="search", + ): await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, diff --git a/tests/components/plex/test_playback.py b/tests/components/plex/test_playback.py index c9dba4e4aca..9ea684256c4 100644 --- a/tests/components/plex/test_playback.py +++ b/tests/components/plex/test_playback.py @@ -49,14 +49,14 @@ async def test_media_player_playback( setup_plex_server, requests_mock: requests_mock.Mocker, playqueue_created, - player_plexweb_resources, + player_plexhtpc_resources, ) -> None: """Test playing media on a Plex media_player.""" - requests_mock.get("http://1.2.3.5:32400/resources", text=player_plexweb_resources) + requests_mock.get("http://1.2.3.6:32400/resources", text=player_plexhtpc_resources) await setup_plex_server() - media_player = "media_player.plex_plex_web_chrome" + media_player = "media_player.plex_plex_htpc_for_mac_plex_htpc" requests_mock.post("/playqueues", text=playqueue_created) playmedia_mock = requests_mock.get( "/player/playback/playMedia", status_code=HTTPStatus.OK @@ -65,7 +65,9 @@ async def test_media_player_playback( # Test media lookup failure payload = '{"library_name": "Movies", "title": "Movie 1" }' with patch( - "plexapi.library.LibrarySection.search", return_value=None + "plexapi.library.LibrarySection.search", + return_value=None, + __qualname__="search", ), pytest.raises(HomeAssistantError) as excinfo: await hass.services.async_call( MP_DOMAIN, @@ -86,7 +88,11 @@ async def test_media_player_playback( # Test movie success movies = [movie1] - with patch("plexapi.library.LibrarySection.search", return_value=movies): + with patch( + "plexapi.library.LibrarySection.search", + return_value=movies, + __qualname__="search", + ): await hass.services.async_call( MP_DOMAIN, SERVICE_PLAY_MEDIA, @@ -101,7 +107,11 @@ async def test_media_player_playback( # Test movie success with resume playmedia_mock.reset() - with patch("plexapi.library.LibrarySection.search", return_value=movies): + with patch( + "plexapi.library.LibrarySection.search", + return_value=movies, + __qualname__="search", + ): await hass.services.async_call( MP_DOMAIN, SERVICE_PLAY_MEDIA, @@ -163,7 +173,11 @@ async def test_media_player_playback( # Test multiple choices with exact match playmedia_mock.reset() movies = [movie1, movie2] - with patch("plexapi.library.LibrarySection.search", return_value=movies): + with patch( + "plexapi.library.LibrarySection.search", + return_value=movies, + __qualname__="search", + ): await hass.services.async_call( MP_DOMAIN, SERVICE_PLAY_MEDIA, @@ -181,7 +195,11 @@ async def test_media_player_playback( movies = [movie2, movie3] with pytest.raises(HomeAssistantError) as excinfo: payload = '{"library_name": "Movies", "title": "Movie" }' - with patch("plexapi.library.LibrarySection.search", return_value=movies): + with patch( + "plexapi.library.LibrarySection.search", + return_value=movies, + __qualname__="search", + ): await hass.services.async_call( MP_DOMAIN, SERVICE_PLAY_MEDIA, @@ -197,7 +215,11 @@ async def test_media_player_playback( # Test multiple choices with allow_multiple movies = [movie1, movie2, movie3] - with patch("plexapi.library.LibrarySection.search", return_value=movies), patch( + with patch( + "plexapi.library.LibrarySection.search", + return_value=movies, + __qualname__="search", + ), patch( "homeassistant.components.plex.server.PlexServer.create_playqueue" ) as mock_create_playqueue: await hass.services.async_call( diff --git a/tests/components/plex/test_sensor.py b/tests/components/plex/test_sensor.py index 9c73bf9f915..5b9729792f4 100644 --- a/tests/components/plex/test_sensor.py +++ b/tests/components/plex/test_sensor.py @@ -129,7 +129,11 @@ async def test_library_sensor_values( ) media = [MockPlexTVEpisode()] - with patch("plexapi.library.LibrarySection.recentlyAdded", return_value=media): + with patch( + "plexapi.library.LibrarySection.recentlyAdded", + return_value=media, + __qualname__="recentlyAdded", + ): await hass.async_block_till_done() library_tv_sensor = hass.states.get("sensor.plex_server_1_library_tv_shows") @@ -165,7 +169,11 @@ async def test_library_sensor_values( trigger_plex_update( mock_websocket, msgtype="status", payload=LIBRARY_UPDATE_PAYLOAD ) - with patch("plexapi.library.LibrarySection.recentlyAdded", return_value=media): + with patch( + "plexapi.library.LibrarySection.recentlyAdded", + return_value=media, + __qualname__="recentlyAdded", + ): await hass.async_block_till_done() library_tv_sensor = hass.states.get("sensor.plex_server_1_library_tv_shows") @@ -200,7 +208,11 @@ async def test_library_sensor_values( ) media = [MockPlexMovie()] - with patch("plexapi.library.LibrarySection.recentlyAdded", return_value=media): + with patch( + "plexapi.library.LibrarySection.recentlyAdded", + return_value=media, + __qualname__="recentlyAdded", + ): await hass.async_block_till_done() library_movies_sensor = hass.states.get("sensor.plex_server_1_library_movies") @@ -210,7 +222,11 @@ async def test_library_sensor_values( # Test with clip media = [MockPlexClip()] - with patch("plexapi.library.LibrarySection.recentlyAdded", return_value=media): + with patch( + "plexapi.library.LibrarySection.recentlyAdded", + return_value=media, + __qualname__="recentlyAdded", + ): async_dispatcher_send( hass, PLEX_UPDATE_LIBRARY_SIGNAL.format(mock_plex_server.machine_identifier) ) @@ -236,7 +252,11 @@ async def test_library_sensor_values( ) media = [MockPlexMusic()] - with patch("plexapi.library.LibrarySection.recentlyAdded", return_value=media): + with patch( + "plexapi.library.LibrarySection.recentlyAdded", + return_value=media, + __qualname__="recentlyAdded", + ): await hass.async_block_till_done() library_music_sensor = hass.states.get("sensor.plex_server_1_library_music") diff --git a/tests/components/plex/test_services.py b/tests/components/plex/test_services.py index a74b3e91460..dfd02bb1d3f 100644 --- a/tests/components/plex/test_services.py +++ b/tests/components/plex/test_services.py @@ -190,7 +190,11 @@ async def test_lookup_media_for_other_integrations( assert result.shuffle # Test with media not found - with patch("plexapi.library.LibrarySection.search", return_value=None): + with patch( + "plexapi.library.LibrarySection.search", + return_value=None, + __qualname__="search", + ): with pytest.raises(HomeAssistantError) as excinfo: process_plex_payload(hass, MediaType.MUSIC, CONTENT_ID_BAD_MEDIA) assert f"No {MediaType.MUSIC} results in 'Music' for" in str(excinfo.value) diff --git a/tests/components/plugwise/snapshots/test_diagnostics.ambr b/tests/components/plugwise/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..da6e8964421 --- /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 6ca1e14a4ca..438ab1b0870 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_USERNAME = "smile" 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_DISCOVERY = ZeroconfServiceInfo( ) 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_DISCOVERY2 = ZeroconfServiceInfo( ) 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_ANNA = ZeroconfServiceInfo( ) 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 69f180692e2..045b8641f69 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,449 +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": { - "02cf28bfec924855854c544690a609ef": { - "available": True, - "dev_class": "vcr", - "firmware": "2019-06-21T02:00:00+02:00", - "location": "cd143c07248f491493cea0533bc3d669", - "model": "Plug", - "name": "NVR", - "sensors": { - "electricity_consumed": 34.0, - "electricity_consumed_interval": 9.15, - "electricity_produced": 0.0, - "electricity_produced_interval": 0.0, - }, - "switches": {"lock": True, "relay": True}, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A15", - }, - "21f2b542c49845e6bb416884c55778d6": { - "available": True, - "dev_class": "game_console", - "firmware": "2019-06-21T02:00:00+02:00", - "location": "cd143c07248f491493cea0533bc3d669", - "model": "Plug", - "name": "Playstation Smart Plug", - "sensors": { - "electricity_consumed": 82.6, - "electricity_consumed_interval": 8.6, - "electricity_produced": 0.0, - "electricity_produced_interval": 0.0, - }, - "switches": {"lock": False, "relay": True}, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A12", - }, - "4a810418d5394b3f82727340b91ba740": { - "available": True, - "dev_class": "router", - "firmware": "2019-06-21T02:00:00+02:00", - "location": "cd143c07248f491493cea0533bc3d669", - "model": "Plug", - "name": "USG Smart Plug", - "sensors": { - "electricity_consumed": 8.5, - "electricity_consumed_interval": 0.0, - "electricity_produced": 0.0, - "electricity_produced_interval": 0.0, - }, - "switches": {"lock": True, "relay": True}, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A16", - }, - "675416a629f343c495449970e2ca37b5": { - "available": True, - "dev_class": "router", - "firmware": "2019-06-21T02:00:00+02:00", - "location": "cd143c07248f491493cea0533bc3d669", - "model": "Plug", - "name": "Ziggo Modem", - "sensors": { - "electricity_consumed": 12.2, - "electricity_consumed_interval": 2.97, - "electricity_produced": 0.0, - "electricity_produced_interval": 0.0, - }, - "switches": {"lock": True, "relay": True}, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A01", - }, - "680423ff840043738f42cc7f1ff97a36": { - "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": { - "battery": 51, - "setpoint": 14.0, - "temperature": 19.1, - "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", - }, - "6a3bf693d05e48e0b460c815a4fdd09d": { - "active_preset": "asleep", - "available": True, - "available_schedules": [ - "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": ["home", "asleep", "away", "vacation", "no_frost"], - "select_schedule": "CV Jessie", - "sensors": {"battery": 37, "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, - "setpoint": 15.0, - "upper_bound": 99.9, - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A03", - }, - "78d1126fc4c743db81b61c20e88342a7": { - "available": True, - "dev_class": "central_heating_pump", - "firmware": "2019-06-21T02:00:00+02:00", - "location": "c50f167537524366a5af7aa3942feb1e", - "model": "Plug", - "name": "CV Pomp", - "sensors": { - "electricity_consumed": 35.6, - "electricity_consumed_interval": 7.37, - "electricity_produced": 0.0, - "electricity_produced_interval": 0.0, - }, - "switches": {"relay": True}, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A05", - }, - "90986d591dcd426cae3ec3e8111ff730": { - "binary_sensors": {"heating_state": True}, - "dev_class": "heater_central", - "location": "1f9dcf83fd4e4b66b72ff787957bfe5d", - "model": "Unknown", - "name": "OnOff", - "sensors": { - "intended_boiler_temperature": 70.0, - "modulation_level": 1, - "water_temperature": 70.0, - }, - }, - "a28f588dc4a049a483fd03a30361ad3a": { - "available": True, - "dev_class": "settop", - "firmware": "2019-06-21T02:00:00+02:00", - "location": "cd143c07248f491493cea0533bc3d669", - "model": "Plug", - "name": "Fibaro HC2", - "sensors": { - "electricity_consumed": 12.5, - "electricity_consumed_interval": 3.8, - "electricity_produced": 0.0, - "electricity_produced_interval": 0.0, - }, - "switches": {"lock": True, "relay": True}, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A13", - }, - "a2c3583e0a6349358998b760cea82d2a": { - "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": { - "battery": 62, - "setpoint": 13.0, - "temperature": 17.2, - "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", - }, - "b310b72a0e354bfab43089919b9a88bf": { - "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": { - "setpoint": 21.5, - "temperature": 26.0, - "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", - }, - "b59bcebaf94b499ea7d46e4a66fb62d8": { - "active_preset": "home", - "available": True, - "available_schedules": [ - "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": ["home", "asleep", "away", "vacation", "no_frost"], - "select_schedule": "GF7 Woonkamer", - "sensors": {"battery": 34, "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, - "setpoint": 21.5, - "upper_bound": 99.9, - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A07", - }, - "cd0ddb54ef694e11ac18ed1cbce5dbbd": { - "available": True, - "dev_class": "vcr", - "firmware": "2019-06-21T02:00:00+02:00", - "location": "cd143c07248f491493cea0533bc3d669", - "model": "Plug", - "name": "NAS", - "sensors": { - "electricity_consumed": 16.5, - "electricity_consumed_interval": 0.5, - "electricity_produced": 0.0, - "electricity_produced_interval": 0.0, - }, - "switches": {"lock": True, "relay": True}, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A14", - }, - "d3da73bde12a47d5a6b8f9dad971f2ec": { - "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": { - "battery": 62, - "setpoint": 15.0, - "temperature": 17.1, - "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", - }, - "df4a4a8169904cdb9c03d61a21f42140": { - "active_preset": "away", - "available": True, - "available_schedules": [ - "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": ["home", "asleep", "away", "vacation", "no_frost"], - "select_schedule": "None", - "sensors": {"battery": 67, "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, - "setpoint": 13.0, - "upper_bound": 99.9, - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A06", - }, - "e7693eb9582644e5b865dba8d4447cf1": { - "active_preset": "no_frost", - "available": True, - "available_schedules": [ - "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": ["home", "asleep", "away", "vacation", "no_frost"], - "select_schedule": "None", - "sensors": { - "battery": 68, - "setpoint": 5.5, - "temperature": 15.6, - "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, - "setpoint": 5.5, - "upper_bound": 100.0, - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A11", - }, - "f1fee6043d3642a9b0a65297455f008e": { - "active_preset": "away", - "available": True, - "available_schedules": [ - "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": ["home", "asleep", "away", "vacation", "no_frost"], - "select_schedule": "Badkamer Schema", - "sensors": {"battery": 92, "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, - "setpoint": 14.0, - "upper_bound": 99.9, - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A08", - }, - "fe799307f1624099878210aa0b9f1475": { - "binary_sensors": {"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": {"outdoor_temperature": 7.81}, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670101", - }, - }, - } + 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 bccf257a433..6fa65b3e65a 100644 --- a/tests/components/plugwise/test_number.py +++ b/tests/components/plugwise/test_number.py @@ -69,3 +69,35 @@ async def test_adam_dhw_setpoint_change( mock_smile_adam_2.set_number_setpoint.assert_called_with( "max_dhw_temperature", "056ee145a816487eaa69243c3280f8bf", 55.0 ) + + +async def test_adam_temperature_offset( + hass: HomeAssistant, mock_smile_adam: MagicMock, init_integration: MockConfigEntry +) -> None: + """Test creation of the temperature_offset number.""" + state = hass.states.get("number.zone_thermostat_jessie_temperature_offset") + assert state + assert float(state.state) == 0.0 + assert state.attributes.get("min") == -2.0 + assert state.attributes.get("max") == 2.0 + assert state.attributes.get("step") == 0.1 + + +async def test_adam_temperature_offset_change( + hass: HomeAssistant, mock_smile_adam: MagicMock, init_integration: MockConfigEntry +) -> None: + """Test changing of the temperature_offset number.""" + 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 00000000000..df9929293a1 --- /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 00000000000..b33dc1d4ea2 --- /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 00000000000..bb58cfedb29 --- /dev/null +++ b/tests/components/private_ble_device/test_config_flow.py @@ -0,0 +1,158 @@ +"""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_invalid_irk_base64(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": "Ucredacted4T8n!!ZZZ=="} + ) + assert_form_error(result, "irk", "irk_not_valid") + + +async def test_invalid_irk_hex(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:abcdefghi"} + ) + 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 00000000000..d8b30738865 --- /dev/null +++ b/tests/components/private_ble_device/test_device_tracker.py @@ -0,0 +1,206 @@ +"""Tests for polling measures.""" + + +import time + +from homeassistant.components.bluetooth.advertisement_tracker import ( + ADVERTISING_TIMES_NEEDED, +) +from homeassistant.components.bluetooth.api import ( + async_get_fallback_availability_interval, +) +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" + + +async def test_mac_rotation( + 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) + + assert async_get_fallback_availability_interval(hass, MAC_RPA_VALID_1) is None + assert async_get_fallback_availability_interval(hass, MAC_RPA_VALID_2) is None + + for i in range(ADVERTISING_TIMES_NEEDED): + await async_inject_broadcast( + hass, MAC_RPA_VALID_1, mfr_data=bytes(i), broadcast_time=i * 10 + ) + + await async_inject_broadcast(hass, MAC_RPA_VALID_2) + assert async_get_fallback_availability_interval(hass, MAC_RPA_VALID_2) == 10 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 00000000000..65f08d5653d --- /dev/null +++ b/tests/components/private_ble_device/test_sensor.py @@ -0,0 +1,108 @@ +"""Tests for sensors.""" + + +from homeassistant.components.bluetooth import async_set_fallback_availability_interval +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, + 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" + + +async def test_estimated_broadcast_interval( + 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) + + # With no fallback and no learned interval, we should use the global default + + state = hass.states.get( + "sensor.private_ble_device_000000_estimated_broadcast_interval" + ) + assert state + assert state.state == "900" + + # Fallback interval trumps const default + + async_set_fallback_availability_interval(hass, MAC_RPA_VALID_1, 90) + await async_inject_broadcast(hass, MAC_RPA_VALID_1.upper()) + + state = hass.states.get( + "sensor.private_ble_device_000000_estimated_broadcast_interval" + ) + assert state + assert state.state == "90" + + # Learned broadcast interval takes over from fallback interval + + for i in range(ADVERTISING_TIMES_NEEDED): + await async_inject_broadcast( + hass, MAC_RPA_VALID_1, mfr_data=bytes(i), broadcast_time=i * 10 + ) + + state = hass.states.get( + "sensor.private_ble_device_000000_estimated_broadcast_interval" + ) + assert state + assert state.state == "10" + + # MAC address changes, the broadcast interval is kept + + await async_inject_broadcast(hass, MAC_RPA_VALID_2.upper()) + + state = hass.states.get( + "sensor.private_ble_device_000000_estimated_broadcast_interval" + ) + assert state + assert state.state == "10" diff --git a/tests/components/prometheus/test_init.py b/tests/components/prometheus/test_init.py index 07a666946fb..f24782b98d4 100644 --- a/tests/components/prometheus/test_init.py +++ b/tests/components/prometheus/test_init.py @@ -24,6 +24,7 @@ from homeassistant.components import ( prometheus, sensor, switch, + update, ) from homeassistant.components.climate import ( ATTR_CURRENT_TEMPERATURE, @@ -572,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, @@ -1591,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 2b00e975a8e..992ce8bbb2c 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 8d66725d20e..26083f51e63 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/rainbird/conftest.py b/tests/components/rainbird/conftest.py index 40b400210aa..f25bdfb1d86 100644 --- a/tests/components/rainbird/conftest.py +++ b/tests/components/rainbird/conftest.py @@ -37,7 +37,7 @@ SERIAL_NUMBER = 0x12635436566 SERIAL_RESPONSE = "850000012635436566" ZERO_SERIAL_RESPONSE = "850000000000000000" # Model and version command 0x82 -MODEL_AND_VERSION_RESPONSE = "820006090C" +MODEL_AND_VERSION_RESPONSE = "820005090C" # ESP-TM2 # Get available stations command 0x83 AVAILABLE_STATIONS_RESPONSE = "83017F000000" # Mask for 7 zones EMPTY_STATIONS_RESPONSE = "830000000000" @@ -86,7 +86,7 @@ def yaml_config() -> dict[str, Any]: @pytest.fixture -async def unique_id() -> str: +async def config_entry_unique_id() -> str: """Fixture for serial number used in the config entry.""" return SERIAL_NUMBER @@ -100,13 +100,13 @@ async def config_entry_data() -> dict[str, Any]: @pytest.fixture async def config_entry( config_entry_data: dict[str, Any] | None, - unique_id: str, + config_entry_unique_id: str | None, ) -> MockConfigEntry | None: """Fixture for MockConfigEntry.""" if config_entry_data is None: return None return MockConfigEntry( - unique_id=unique_id, + unique_id=config_entry_unique_id, domain=DOMAIN, data=config_entry_data, options={ATTR_DURATION: DEFAULT_TRIGGER_TIME_MINUTES}, @@ -184,8 +184,15 @@ def mock_rain_delay_response() -> str: return RAIN_DELAY_OFF +@pytest.fixture(name="model_and_version_response") +def mock_model_and_version_response() -> str: + """Mock response to return rain delay state.""" + return MODEL_AND_VERSION_RESPONSE + + @pytest.fixture(name="api_responses") def mock_api_responses( + model_and_version_response: str, stations_response: str, zone_state_response: str, rain_response: str, @@ -196,7 +203,7 @@ def mock_api_responses( These are returned in the order they are requested by the update coordinator. """ return [ - MODEL_AND_VERSION_RESPONSE, + model_and_version_response, stations_response, zone_state_response, rain_response, diff --git a/tests/components/rainbird/test_binary_sensor.py b/tests/components/rainbird/test_binary_sensor.py index cfa2c4d2684..e372a10ae23 100644 --- a/tests/components/rainbird/test_binary_sensor.py +++ b/tests/components/rainbird/test_binary_sensor.py @@ -5,6 +5,7 @@ import pytest from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from .conftest import RAIN_SENSOR_OFF, RAIN_SENSOR_ON, ComponentSetup @@ -25,6 +26,7 @@ async def test_rainsensor( hass: HomeAssistant, setup_integration: ComponentSetup, responses: list[AiohttpClientMockResponse], + entity_registry: er.EntityRegistry, expected_state: bool, ) -> None: """Test rainsensor binary sensor.""" @@ -38,3 +40,37 @@ async def test_rainsensor( "friendly_name": "Rain Bird Controller Rainsensor", "icon": "mdi:water", } + + entity_entry = entity_registry.async_get( + "binary_sensor.rain_bird_controller_rainsensor" + ) + assert entity_entry + assert entity_entry.unique_id == "1263613994342-rainsensor" + + +@pytest.mark.parametrize( + ("config_entry_unique_id"), + [ + (None), + ], +) +async def test_no_unique_id( + hass: HomeAssistant, + setup_integration: ComponentSetup, + responses: list[AiohttpClientMockResponse], + entity_registry: er.EntityRegistry, +) -> None: + """Test rainsensor binary sensor with no unique id.""" + + assert await setup_integration() + + rainsensor = hass.states.get("binary_sensor.rain_bird_controller_rainsensor") + assert rainsensor is not None + assert ( + rainsensor.attributes.get("friendly_name") == "Rain Bird Controller Rainsensor" + ) + + entity_entry = entity_registry.async_get( + "binary_sensor.rain_bird_controller_rainsensor" + ) + assert not entity_entry diff --git a/tests/components/rainbird/test_calendar.py b/tests/components/rainbird/test_calendar.py new file mode 100644 index 00000000000..2e486226a7b --- /dev/null +++ b/tests/components/rainbird/test_calendar.py @@ -0,0 +1,302 @@ +"""Tests for rainbird calendar platform.""" + + +from collections.abc import Awaitable, Callable +import datetime +from http import HTTPStatus +from typing import Any +import urllib +from zoneinfo import ZoneInfo + +from aiohttp import ClientSession +from freezegun.api import FrozenDateTimeFactory +import pytest + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .conftest import ComponentSetup, mock_response, mock_response_error + +from tests.test_util.aiohttp import AiohttpClientMockResponse + +TEST_ENTITY = "calendar.rain_bird_controller" +GetEventsFn = Callable[[str, str], Awaitable[dict[str, Any]]] + +SCHEDULE_RESPONSES = [ + # Current controller status + "A0000000000000", + # Per-program information + "A00010060602006400", # CUSTOM: Monday & Tuesday + "A00011110602006400", + "A00012000300006400", + # Start times per program + "A0006000F0FFFFFFFFFFFF", # 4am + "A00061FFFFFFFFFFFFFFFF", + "A00062FFFFFFFFFFFFFFFF", + # Run times for each zone + "A00080001900000000001400000000", # zone1=25, zone2=20 + "A00081000700000000001400000000", # zone3=7, zone4=20 + "A00082000A00000000000000000000", # zone5=10 + "A00083000000000000000000000000", + "A00084000000000000000000000000", + "A00085000000000000000000000000", + "A00086000000000000000000000000", + "A00087000000000000000000000000", + "A00088000000000000000000000000", + "A00089000000000000000000000000", + "A0008A000000000000000000000000", +] + +EMPTY_SCHEDULE_RESPONSES = [ + # Current controller status + "A0000000000000", + # Per-program information (ignored) + "A00010000000000000", + "A00011000000000000", + "A00012000000000000", + # Start times for each program (off) + "A00060FFFFFFFFFFFFFFFF", + "A00061FFFFFFFFFFFFFFFF", + "A00062FFFFFFFFFFFFFFFF", + # Run times for each zone + "A00080000000000000000000000000", + "A00081000000000000000000000000", + "A00082000000000000000000000000", + "A00083000000000000000000000000", + "A00084000000000000000000000000", + "A00085000000000000000000000000", + "A00086000000000000000000000000", + "A00087000000000000000000000000", + "A00088000000000000000000000000", + "A00089000000000000000000000000", + "A0008A000000000000000000000000", +] + + +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [Platform.CALENDAR] + + +@pytest.fixture(autouse=True) +def set_time_zone(hass: HomeAssistant): + """Set the time zone for the tests.""" + hass.config.set_time_zone("America/Regina") + + +@pytest.fixture(autouse=True) +def mock_schedule_responses() -> list[str]: + """Fixture containing fake irrigation schedule.""" + return SCHEDULE_RESPONSES + + +@pytest.fixture(autouse=True) +def mock_insert_schedule_response( + mock_schedule_responses: list[str], responses: list[AiohttpClientMockResponse] +) -> None: + """Fixture to insert device responses for the irrigation schedule.""" + responses.extend( + [mock_response(api_response) for api_response in mock_schedule_responses] + ) + + +@pytest.fixture(name="get_events") +def get_events_fixture( + hass_client: Callable[..., Awaitable[ClientSession]] +) -> GetEventsFn: + """Fetch calendar events from the HTTP API.""" + + async def _fetch(start: str, end: str) -> list[dict[str, Any]]: + client = await hass_client() + response = await client.get( + f"/api/calendars/{TEST_ENTITY}?start={urllib.parse.quote(start)}&end={urllib.parse.quote(end)}" + ) + assert response.status == HTTPStatus.OK + results = await response.json() + return [{k: event[k] for k in {"summary", "start", "end"}} for event in results] + + return _fetch + + +@pytest.mark.freeze_time("2023-01-21 09:32:00") +async def test_get_events( + hass: HomeAssistant, setup_integration: ComponentSetup, get_events: GetEventsFn +) -> None: + """Test calendar event fetching APIs.""" + + assert await setup_integration() + + events = await get_events("2023-01-20T00:00:00Z", "2023-02-05T00:00:00Z") + assert events == [ + # Monday + { + "summary": "PGM A", + "start": {"dateTime": "2023-01-23T04:00:00-06:00"}, + "end": {"dateTime": "2023-01-23T05:22:00-06:00"}, + }, + # Tuesday + { + "summary": "PGM A", + "start": {"dateTime": "2023-01-24T04:00:00-06:00"}, + "end": {"dateTime": "2023-01-24T05:22:00-06:00"}, + }, + # Monday + { + "summary": "PGM A", + "start": {"dateTime": "2023-01-30T04:00:00-06:00"}, + "end": {"dateTime": "2023-01-30T05:22:00-06:00"}, + }, + # Tuesday + { + "summary": "PGM A", + "start": {"dateTime": "2023-01-31T04:00:00-06:00"}, + "end": {"dateTime": "2023-01-31T05:22:00-06:00"}, + }, + ] + + +@pytest.mark.parametrize( + ("freeze_time", "expected_state"), + [ + ( + datetime.datetime(2023, 1, 23, 3, 50, tzinfo=ZoneInfo("America/Regina")), + "off", + ), + ( + datetime.datetime(2023, 1, 23, 4, 30, tzinfo=ZoneInfo("America/Regina")), + "on", + ), + ], +) +async def test_event_state( + hass: HomeAssistant, + setup_integration: ComponentSetup, + get_events: GetEventsFn, + freezer: FrozenDateTimeFactory, + freeze_time: datetime.datetime, + expected_state: str, + entity_registry: er.EntityRegistry, +) -> None: + """Test calendar upcoming event state.""" + freezer.move_to(freeze_time) + + assert await setup_integration() + + state = hass.states.get(TEST_ENTITY) + assert state is not None + assert state.attributes == { + "message": "PGM A", + "start_time": "2023-01-23 04:00:00", + "end_time": "2023-01-23 05:22:00", + "all_day": False, + "description": "", + "location": "", + "friendly_name": "Rain Bird Controller", + "icon": "mdi:sprinkler", + } + assert state.state == expected_state + + entity = entity_registry.async_get(TEST_ENTITY) + assert entity + assert entity.unique_id == 1263613994342 + + +@pytest.mark.parametrize( + ("model_and_version_response", "has_entity"), + [ + ("820005090C", True), + ("820006090C", False), + ], + ids=("ESP-TM2", "ST8x-WiFi"), +) +async def test_calendar_not_supported_by_device( + hass: HomeAssistant, + setup_integration: ComponentSetup, + has_entity: bool, +) -> None: + """Test calendar upcoming event state.""" + + assert await setup_integration() + + state = hass.states.get(TEST_ENTITY) + assert (state is not None) == has_entity + + +@pytest.mark.parametrize( + "mock_insert_schedule_response", [([None])] # Disable success responses +) +async def test_no_schedule( + hass: HomeAssistant, + setup_integration: ComponentSetup, + get_events: GetEventsFn, + responses: list[AiohttpClientMockResponse], + hass_client: Callable[..., Awaitable[ClientSession]], +) -> None: + """Test calendar error when fetching the calendar.""" + responses.extend([mock_response_error(HTTPStatus.BAD_GATEWAY)]) # Arbitrary error + + assert await setup_integration() + + state = hass.states.get(TEST_ENTITY) + assert state.state == "unavailable" + assert state.attributes == { + "friendly_name": "Rain Bird Controller", + "icon": "mdi:sprinkler", + } + + client = await hass_client() + response = await client.get( + f"/api/calendars/{TEST_ENTITY}?start=2023-08-01&end=2023-08-02" + ) + assert response.status == HTTPStatus.INTERNAL_SERVER_ERROR + + +@pytest.mark.freeze_time("2023-01-21 09:32:00") +@pytest.mark.parametrize( + "mock_schedule_responses", + [(EMPTY_SCHEDULE_RESPONSES)], +) +async def test_program_schedule_disabled( + hass: HomeAssistant, + setup_integration: ComponentSetup, + get_events: GetEventsFn, +) -> None: + """Test calendar when the program is disabled with no upcoming events.""" + + assert await setup_integration() + + events = await get_events("2023-01-20T00:00:00Z", "2023-02-05T00:00:00Z") + assert events == [] + + state = hass.states.get(TEST_ENTITY) + assert state.state == "off" + assert state.attributes == { + "friendly_name": "Rain Bird Controller", + "icon": "mdi:sprinkler", + } + + +@pytest.mark.parametrize( + ("config_entry_unique_id"), + [ + (None), + ], +) +async def test_no_unique_id( + hass: HomeAssistant, + setup_integration: ComponentSetup, + get_events: GetEventsFn, + entity_registry: er.EntityRegistry, +) -> None: + """Test calendar entity with no unique id.""" + + assert await setup_integration() + + state = hass.states.get(TEST_ENTITY) + assert state is not None + assert state.attributes.get("friendly_name") == "Rain Bird Controller" + + entity_entry = entity_registry.async_get(TEST_ENTITY) + assert not entity_entry diff --git a/tests/components/rainbird/test_config_flow.py b/tests/components/rainbird/test_config_flow.py index e7337ad6508..cfc4ff3b5cb 100644 --- a/tests/components/rainbird/test_config_flow.py +++ b/tests/components/rainbird/test_config_flow.py @@ -106,7 +106,7 @@ async def test_controller_flow( @pytest.mark.parametrize( ( - "unique_id", + "config_entry_unique_id", "config_entry_data", "config_flow_responses", "expected_config_entry", @@ -154,7 +154,7 @@ async def test_multiple_config_entries( @pytest.mark.parametrize( ( - "unique_id", + "config_entry_unique_id", "config_entry_data", "config_flow_responses", ), diff --git a/tests/components/rainbird/test_number.py b/tests/components/rainbird/test_number.py index 2c837a75c66..5d208f08a25 100644 --- a/tests/components/rainbird/test_number.py +++ b/tests/components/rainbird/test_number.py @@ -10,7 +10,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, entity_registry as er from .conftest import ( ACK_ECHO, @@ -39,8 +39,9 @@ async def test_number_values( hass: HomeAssistant, setup_integration: ComponentSetup, expected_state: str, + entity_registry: er.EntityRegistry, ) -> None: - """Test sensor platform.""" + """Test number platform.""" assert await setup_integration() @@ -57,6 +58,10 @@ async def test_number_values( "unit_of_measurement": "d", } + entity_entry = entity_registry.async_get("number.rain_bird_controller_rain_delay") + assert entity_entry + assert entity_entry.unique_id == "1263613994342-rain-delay" + async def test_set_value( hass: HomeAssistant, @@ -73,7 +78,7 @@ async def test_set_value( device = device_registry.async_get_device(identifiers={(DOMAIN, SERIAL_NUMBER)}) assert device assert device.name == "Rain Bird Controller" - assert device.model == "ST8x-WiFi" + assert device.model == "ESP-TM2" assert device.sw_version == "9.12" aioclient_mock.mock_calls.clear() @@ -127,3 +132,28 @@ async def test_set_value_error( ) assert len(aioclient_mock.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("config_entry_unique_id"), + [ + (None), + ], +) +async def test_no_unique_id( + hass: HomeAssistant, + setup_integration: ComponentSetup, + entity_registry: er.EntityRegistry, +) -> None: + """Test number platform with no unique id.""" + + assert await setup_integration() + + raindelay = hass.states.get("number.rain_bird_controller_rain_delay") + assert raindelay is not None + assert ( + raindelay.attributes.get("friendly_name") == "Rain Bird Controller Rain delay" + ) + + entity_entry = entity_registry.async_get("number.rain_bird_controller_rain_delay") + assert not entity_entry diff --git a/tests/components/rainbird/test_sensor.py b/tests/components/rainbird/test_sensor.py index 049a5f15c45..d8fb053c0ff 100644 --- a/tests/components/rainbird/test_sensor.py +++ b/tests/components/rainbird/test_sensor.py @@ -5,8 +5,9 @@ import pytest from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er -from .conftest import RAIN_DELAY, RAIN_DELAY_OFF, ComponentSetup +from .conftest import CONFIG_ENTRY_DATA, RAIN_DELAY, RAIN_DELAY_OFF, ComponentSetup @pytest.fixture @@ -22,6 +23,7 @@ def platforms() -> list[str]: async def test_sensors( hass: HomeAssistant, setup_integration: ComponentSetup, + entity_registry: er.EntityRegistry, expected_state: str, ) -> None: """Test sensor platform.""" @@ -35,3 +37,46 @@ async def test_sensors( "friendly_name": "Rain Bird Controller Raindelay", "icon": "mdi:water-off", } + + entity_entry = entity_registry.async_get("sensor.rain_bird_controller_raindelay") + assert entity_entry + assert entity_entry.unique_id == "1263613994342-raindelay" + + +@pytest.mark.parametrize( + ("config_entry_unique_id", "config_entry_data"), + [ + # Config entry setup without a unique id since it had no serial number + ( + None, + { + **CONFIG_ENTRY_DATA, + "serial_number": 0, + }, + ), + # Legacy case for old config entries with serial number 0 preserves old behavior + ( + "0", + { + **CONFIG_ENTRY_DATA, + "serial_number": 0, + }, + ), + ], +) +async def test_sensor_no_unique_id( + hass: HomeAssistant, + setup_integration: ComponentSetup, + entity_registry: er.EntityRegistry, + config_entry_unique_id: str | None, +) -> None: + """Test sensor platform with no unique id.""" + + assert await setup_integration() + + raindelay = hass.states.get("sensor.rain_bird_controller_raindelay") + assert raindelay is not None + assert raindelay.attributes.get("friendly_name") == "Rain Bird Controller Raindelay" + + entity_entry = entity_registry.async_get("sensor.rain_bird_controller_raindelay") + assert (entity_entry is None) == (config_entry_unique_id is None) diff --git a/tests/components/rainbird/test_switch.py b/tests/components/rainbird/test_switch.py index 9127a0b0c61..46a875e8928 100644 --- a/tests/components/rainbird/test_switch.py +++ b/tests/components/rainbird/test_switch.py @@ -8,6 +8,7 @@ from homeassistant.components.rainbird import DOMAIN from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er from .conftest import ( ACK_ECHO, @@ -57,7 +58,7 @@ async def test_no_zones( async def test_zones( hass: HomeAssistant, setup_integration: ComponentSetup, - responses: list[AiohttpClientMockResponse], + entity_registry: er.EntityRegistry, ) -> None: """Test switch platform with fake data that creates 7 zones with one enabled.""" @@ -101,6 +102,10 @@ async def test_zones( assert not hass.states.get("switch.rain_bird_sprinkler_8") + # Verify unique id for one of the switches + entity_entry = entity_registry.async_get("switch.rain_bird_sprinkler_3") + assert entity_entry.unique_id == "1263613994342-3" + async def test_switch_on( hass: HomeAssistant, @@ -276,3 +281,29 @@ async def test_switch_error( with pytest.raises(HomeAssistantError, match=expected_msg): await switch_common.async_turn_off(hass, "switch.rain_bird_sprinkler_3") await hass.async_block_till_done() + + +@pytest.mark.parametrize( + ("config_entry_unique_id"), + [ + None, + ], +) +async def test_no_unique_id( + hass: HomeAssistant, + setup_integration: ComponentSetup, + aioclient_mock: AiohttpClientMocker, + responses: list[AiohttpClientMockResponse], + entity_registry: er.EntityRegistry, +) -> None: + """Test an irrigation switch with no unique id.""" + + assert await setup_integration() + + zone = hass.states.get("switch.rain_bird_sprinkler_3") + assert zone is not None + assert zone.attributes.get("friendly_name") == "Rain Bird Sprinkler 3" + assert zone.state == "off" + + entity_entry = entity_registry.async_get("switch.rain_bird_sprinkler_3") + assert entity_entry is None diff --git a/tests/components/rainmachine/test_config_flow.py b/tests/components/rainmachine/test_config_flow.py index 0d95cbcce31..5fa457bf771 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/raspberry_pi/test_init.py b/tests/components/raspberry_pi/test_init.py index 4bf64c7999a..b0e9ef89582 100644 --- a/tests/components/raspberry_pi/test_init.py +++ b/tests/components/raspberry_pi/test_init.py @@ -58,13 +58,17 @@ async def test_setup_entry_wrong_board(hass: HomeAssistant) -> None: title="Raspberry Pi", ) config_entry.add_to_hass(hass) + assert len(hass.config_entries.async_entries()) == 1 + with patch( "homeassistant.components.raspberry_pi.get_os_info", return_value={"board": "generic-x86-64"}, ) as mock_get_os_info: assert not await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert len(mock_get_os_info.mock_calls) == 1 + + assert len(mock_get_os_info.mock_calls) == 1 + assert len(hass.config_entries.async_entries()) == 0 async def test_setup_entry_wait_hassio(hass: HomeAssistant) -> None: @@ -85,5 +89,6 @@ async def test_setup_entry_wait_hassio(hass: HomeAssistant) -> None: ) as mock_get_os_info: assert not await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert len(mock_get_os_info.mock_calls) == 1 - assert config_entry.state == ConfigEntryState.SETUP_RETRY + + assert len(mock_get_os_info.mock_calls) == 1 + assert config_entry.state == ConfigEntryState.SETUP_RETRY diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py index e4e5e49eab5..0dfbb6005c4 100644 --- a/tests/components/recorder/test_init.py +++ b/tests/components/recorder/test_init.py @@ -119,7 +119,6 @@ def _default_recorder(hass): db_retry_wait=3, entity_filter=CONFIG_SCHEMA({DOMAIN: {}}), exclude_event_types=set(), - exclude_attributes_by_domain={}, ) @@ -2264,17 +2263,14 @@ async def test_connect_args_priority(hass: HomeAssistant, config_url) -> None: assert connect_params[0]["charset"] == "utf8mb4" -@pytest.mark.parametrize("core_state", [CoreState.starting, CoreState.running]) async def test_excluding_attributes_by_integration( recorder_mock: Recorder, hass: HomeAssistant, entity_registry: er.EntityRegistry, - core_state: CoreState, ) -> None: - """Test that an integration's recorder platform can exclude attributes.""" - hass.state = core_state + """Test that an entity can exclude attributes from being recorded.""" state = "restoring_from_db" - attributes = {"test_attr": 5, "excluded": 10} + attributes = {"test_attr": 5, "excluded_component": 10, "excluded_integration": 20} mock_platform( hass, "fake_integration.recorder", @@ -2284,10 +2280,17 @@ async def test_excluding_attributes_by_integration( hass.bus.async_fire(EVENT_COMPONENT_LOADED, {"component": "fake_integration"}) await hass.async_block_till_done() + class EntityWithExcludedAttributes(MockEntity): + _entity_component_unrecorded_attributes = frozenset({"excluded_component"}) + _unrecorded_attributes = frozenset({"excluded_integration"}) + entity_id = "test.fake_integration_recorder" - platform = MockEntityPlatform(hass, platform_name="fake_integration") - entity_platform = MockEntity(entity_id=entity_id, extra_state_attributes=attributes) - await platform.async_add_entities([entity_platform]) + entity_platform = MockEntityPlatform(hass, platform_name="fake_integration") + entity = EntityWithExcludedAttributes( + entity_id=entity_id, + extra_state_attributes=attributes, + ) + await entity_platform.async_add_entities([entity]) await hass.async_block_till_done() await async_wait_recording_done(hass) diff --git a/tests/components/recorder/test_migration_from_schema_32.py b/tests/components/recorder/test_migration_from_schema_32.py index cdf930fde26..e007d2408dd 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 @@ SCHEMA_MODULE = "tests.components.recorder.db_schema_32" 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 @@ async def test_migrate_events_context_ids( 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 @@ async def test_migrate_events_context_ids( 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 @@ async def test_migrate_events_context_ids( 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 @@ async def test_migrate_events_context_ids( 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 @@ async def test_migrate_events_context_ids( 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 @@ async def test_migrate_events_context_ids( 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 @@ async def test_migrate_events_context_ids( 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 @@ async def test_migrate_states_context_ids( 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 @@ async def test_migrate_states_context_ids( 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 @@ async def test_migrate_states_context_ids( 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 @@ async def test_migrate_states_context_ids( 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 @@ async def test_migrate_states_context_ids( 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 @@ async def test_migrate_states_context_ids( 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 @@ async def test_migrate_states_context_ids( 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 @@ async def test_migrate_event_type_ids( 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 @@ async def test_migrate_entity_ids( 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 @@ async def test_post_migrate_entity_ids( 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 @@ async def test_migrate_null_entity_ids( 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 @@ async def test_migrate_null_entity_ids( 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 @@ async def test_migrate_null_event_type_ids( 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 @@ async def test_migrate_null_event_type_ids( 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/recorder/test_models.py b/tests/components/recorder/test_models.py index c73a0db6c76..f5ea8ff1656 100644 --- a/tests/components/recorder/test_models.py +++ b/tests/components/recorder/test_models.py @@ -77,7 +77,7 @@ def test_from_event_to_db_state_attributes() -> None: dialect = SupportedDialect.MYSQL db_attrs.shared_attrs = StateAttributes.shared_attrs_bytes_from_event( - event, {}, {}, dialect + event, {}, dialect ) assert db_attrs.to_native() == attrs diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index ab89b82d713..e56b2b83274 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -24,6 +24,7 @@ from homeassistant.components.recorder.statistics import ( get_last_statistics, get_latest_short_term_statistics, get_metadata, + get_short_term_statistics_run_cache, list_statistic_ids, ) from homeassistant.components.recorder.table_managers.statistics_meta import ( @@ -176,6 +177,15 @@ def test_compile_hourly_statistics(hass_recorder: Callable[..., HomeAssistant]) ) assert stats == {"sensor.test1": [expected_2]} + # Now wipe the latest_short_term_statistics_ids table and test again + # to make sure we can rebuild the missing data + run_cache = get_short_term_statistics_run_cache(instance.hass) + run_cache._latest_id_by_metadata_id = {} + stats = get_latest_short_term_statistics( + hass, {"sensor.test1"}, {"last_reset", "max", "mean", "min", "state", "sum"} + ) + assert stats == {"sensor.test1": [expected_2]} + metadata = get_metadata(hass, statistic_ids={"sensor.test1"}) stats = get_latest_short_term_statistics( @@ -220,6 +230,17 @@ def test_compile_hourly_statistics(hass_recorder: Callable[..., HomeAssistant]) ) assert stats == {} + # Delete again, and manually wipe the cache since we deleted all the data + instance.get_session().query(StatisticsShortTerm).delete() + run_cache = get_short_term_statistics_run_cache(instance.hass) + run_cache._latest_id_by_metadata_id = {} + + # And test again to make sure there is no data + stats = get_latest_short_term_statistics( + hass, {"sensor.test1"}, {"last_reset", "max", "mean", "min", "state", "sum"} + ) + assert stats == {} + @pytest.fixture def mock_sensor_statistics(): diff --git a/tests/components/recorder/test_websocket_api.py b/tests/components/recorder/test_websocket_api.py index a9dc23ef5b3..38b657945f7 100644 --- a/tests/components/recorder/test_websocket_api.py +++ b/tests/components/recorder/test_websocket_api.py @@ -15,6 +15,7 @@ from homeassistant.components.recorder.statistics import ( async_add_external_statistics, get_last_statistics, get_metadata, + get_short_term_statistics_run_cache, list_statistic_ids, ) from homeassistant.components.recorder.websocket_api import UNIT_SCHEMA @@ -302,6 +303,13 @@ async def test_statistic_during_period( ) await async_wait_recording_done(hass) + metadata = get_metadata(hass, statistic_ids={"sensor.test"}) + metadata_id = metadata["sensor.test"][0] + run_cache = get_short_term_statistics_run_cache(hass) + # Verify the import of the short term statistics + # also updates the run cache + assert run_cache.get_latest_ids({metadata_id}) is not None + # No data for this period yet await client.send_json( { diff --git a/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py index ef841769f8d..3435bd58cb3 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 6a2e1f4b5f1..87ed02bc3ec 100644 --- a/tests/components/roborock/mock_data.py +++ b/tests/components/roborock/mock_data.py @@ -13,6 +13,9 @@ from roborock.containers import ( ) 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 @@ USER_DATA = UserData.from_dict( ) 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 @@ HOME_DATA_RAW = { "geoName": None, "products": [ { - "id": "abc123", + "id": "s7_product", "name": "Roborock S7 MaxV", "code": "a27", "model": "roborock.vacuum.a27", @@ -227,7 +230,7 @@ HOME_DATA_RAW = { "runtimeEnv": None, "timeZoneId": "America/Los_Angeles", "iconUrl": "", - "productId": "abc123", + "productId": "s7_product", "lon": None, "lat": None, "share": False, @@ -255,7 +258,45 @@ HOME_DATA_RAW = { "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 a766a6c2703..d8e5f7d4cb2 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,268 @@ 'capability': 0, 'category': 'robot.vacuum.cleaner', 'code': 'a27', - 'id': 'abc123', + '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, + '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([ diff --git a/tests/components/roborock/test_binary_sensor.py b/tests/components/roborock/test_binary_sensor.py new file mode 100644 index 00000000000..4edf8ff4710 --- /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 05bf0848475..a5ad24b431c 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 19648343bb4..35fcc9478cd 100644 --- a/tests/components/roborock/test_sensor.py +++ b/tests/components/roborock/test_sensor.py @@ -14,7 +14,7 @@ from tests.common import MockConfigEntry 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/roborock/test_switch.py b/tests/components/roborock/test_switch.py index 40ecdc267ed..fb301390fee 100644 --- a/tests/components/roborock/test_switch.py +++ b/tests/components/roborock/test_switch.py @@ -27,7 +27,7 @@ async def test_update_success( # Ensure that the entity exist, as these test can pass even if there is no entity. assert hass.states.get(entity_id) is not None with patch( - "homeassistant.components.roborock.coordinator.RoborockLocalClient.send_message" + "homeassistant.components.roborock.coordinator.RoborockLocalClient._send_command" ) as mock_send_message: await hass.services.async_call( "switch", diff --git a/tests/components/roborock/test_time.py b/tests/components/roborock/test_time.py index 6ba996ca23f..1cf2fe6bed5 100644 --- a/tests/components/roborock/test_time.py +++ b/tests/components/roborock/test_time.py @@ -27,7 +27,7 @@ async def test_update_success( # Ensure that the entity exist, as these test can pass even if there is no entity. assert hass.states.get(entity_id) is not None with patch( - "homeassistant.components.roborock.coordinator.RoborockLocalClient.send_message" + "homeassistant.components.roborock.coordinator.RoborockLocalClient._send_command" ) as mock_send_message: await hass.services.async_call( "time", diff --git a/tests/components/roku/__init__.py b/tests/components/roku/__init__.py index 2ae0b308f9a..fc12bb9731d 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 @@ MOCK_SSDP_DISCOVERY_INFO = ssdp.SsdpServiceInfo( 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 0b39c34d3b8..f62ca1a73b9 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 @@ DISCOVERY_DEVICES = [ ( 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 c55d531b0cb..cd74395fa66 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.const import ( 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,13 @@ 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() @@ -76,20 +93,181 @@ 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" + assert result["step_id"] == "user" + assert result["errors"] == {} - 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", + 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, ) - 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"] == {} + + 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() + 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=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, + ) + + 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,10 +284,27 @@ 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_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=Exception)): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + CONFIG, + ) + + assert result2["type"] == data_entry_flow.FlowResultType.FORM + assert result2["step_id"] == "user" + assert result2["errors"] == {"base": "unknown"} + + async def test_form_unexpected_response(hass: HomeAssistant) -> None: """Test we handle unknown error.""" result = await hass.config_entries.flow.async_init( @@ -126,25 +321,7 @@ async def test_form_unexpected_response(hass: HomeAssistant) -> None: CONFIG, ) - assert result2["type"] == "form" - assert result2["errors"] == {"base": "cannot_connect"} - - -async def test_form_cannot_connect_unknown_serial(hass: HomeAssistant) -> None: - """Test we handle cannot connect error on invalid serial number.""" - 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={}): - 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 00000000000..f8b11bd864a --- /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 3c4b982b000..a70a0042fcd 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 @@ MOCK_DHCP_DATA = dhcp.DhcpServiceInfo( ) 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 7491f3b76b7..526f7a12fed 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 @@ from homeassistant.const import ( 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 test_setup_updates_from_ssdp(hass: HomeAssistant) -> None: 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 0078e6a5553..7b610a6b4da 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 00000000000..4673f263c8c --- /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 ad2b82960f0..e5400e3ca15 100644 --- a/tests/components/screenlogic/__init__.py +++ b/tests/components/screenlogic/__init__.py @@ -1 +1,76 @@ """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_FULL_NO_GPM = num_key_string_to_int( + load_json_object_fixture("screenlogic/data_full_no_gpm.json") +) +DATA_FULL_NO_SALT_PPM = num_key_string_to_int( + load_json_object_fixture("screenlogic/data_full_no_salt_ppm.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") +) +DATA_MISSING_VALUES_CHEM_CHLOR = num_key_string_to_int( + load_json_object_fixture("screenlogic/data_missing_values_chem_chlor.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 00000000000..3795df3dddc --- /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 00000000000..6c9ece22fcf --- /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_full_no_gpm.json b/tests/components/screenlogic/fixtures/data_full_no_gpm.json new file mode 100644 index 00000000000..93e3040f911 --- /dev/null +++ b/tests/components/screenlogic/fixtures/data_full_no_gpm.json @@ -0,0 +1,784 @@ +{ + "adapter": { + "firmware": { + "name": "Protocol Adapter Firmware", + "value": "POOL: 5.2 Build 738.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": 1, + "hardware_type": 0, + "controller_data": 0, + "generic_circuit_name": "Water Features", + "circuit_count": 7, + "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": 1, + "remotes": 0, + "unknown_at_offset_09": 0, + "unknown_at_offset_10": 0, + "unknown_at_offset_11": 0 + }, + "model": { + "name": "Model", + "value": "IntelliTouch i7+3" + }, + "equipment": { + "flags": 56, + "list": ["INTELLIBRITE", "INTELLIFLO_0", "INTELLIFLO_1"] + }, + "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": 91, + "unit": "\u00b0F", + "device_type": "temperature", + "state_type": "measurement" + }, + "ph": { + "name": "pH", + "value": 0.0, + "unit": "pH", + "state_type": "measurement" + }, + "orp": { + "name": "ORP", + "value": 0, + "unit": "mV", + "state_type": "measurement" + }, + "saturation": { + "name": "Saturation Index", + "value": 0.0, + "unit": "lsi", + "state_type": "measurement" + }, + "salt_ppm": { + "name": "Salt", + "value": 0, + "unit": "ppm", + "state_type": "measurement" + }, + "ph_supply_level": { + "name": "pH Supply Level", + "value": 0, + "state_type": "measurement" + }, + "orp_supply_level": { + "name": "ORP Supply Level", + "value": 0, + "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": "Cleaner", + "configuration": { + "name_index": 21, + "flags": 0, + "default_runtime": 720, + "unknown_at_offset_90": 0, + "unknown_at_offset_91": 0, + "delay": 0 + }, + "function": 5, + "interface": 5, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 2, + "value": 0 + }, + "502": { + "circuit_id": 502, + "name": "Jets", + "configuration": { + "name_index": 45, + "flags": 0, + "default_runtime": 720, + "unknown_at_offset_114": 0, + "unknown_at_offset_115": 0, + "delay": 0 + }, + "function": 0, + "interface": 5, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 3, + "value": 0 + }, + "503": { + "circuit_id": 503, + "name": "Pool Light", + "configuration": { + "name_index": 62, + "flags": 0, + "default_runtime": 720, + "unknown_at_offset_146": 0, + "unknown_at_offset_147": 0, + "delay": 0 + }, + "function": 16, + "interface": 3, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 10 + }, + "device_id": 4, + "value": 0 + }, + "504": { + "circuit_id": 504, + "name": "Spa Light", + "configuration": { + "name_index": 73, + "flags": 0, + "default_runtime": 720, + "unknown_at_offset_178": 0, + "unknown_at_offset_179": 0, + "delay": 0 + }, + "function": 16, + "interface": 3, + "color": { + "color_set": 0, + "color_position": 1, + "color_stagger": 10 + }, + "device_id": 5, + "value": 0 + }, + "505": { + "circuit_id": 505, + "name": "Pool", + "configuration": { + "name_index": 60, + "flags": 1, + "default_runtime": 720, + "unknown_at_offset_202": 0, + "unknown_at_offset_203": 0, + "delay": 0 + }, + "function": 2, + "interface": 0, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 6, + "value": 1 + }, + "506": { + "circuit_id": 506, + "name": "Air Blower", + "configuration": { + "name_index": 1, + "flags": 0, + "default_runtime": 720, + "unknown_at_offset_234": 0, + "unknown_at_offset_235": 0, + "delay": 0 + }, + "function": 0, + "interface": 5, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 7, + "value": 0 + } + }, + "pump": { + "0": { + "data": 134, + "type": 2, + "state": { + "name": "Pool Pump", + "value": 1 + }, + "watts_now": { + "name": "Pool Pump Watts Now", + "value": 63, + "unit": "W", + "device_type": "power", + "state_type": "measurement" + }, + "rpm_now": { + "name": "Pool Pump RPM Now", + "value": 1050, + "unit": "rpm", + "state_type": "measurement" + }, + "unknown_at_offset_16": 0, + "unknown_at_offset_24": 255, + "preset": { + "0": { + "device_id": 6, + "setpoint": 1050, + "is_rpm": 1 + }, + "1": { + "device_id": 1, + "setpoint": 1850, + "is_rpm": 1 + }, + "2": { + "device_id": 2, + "setpoint": 1500, + "is_rpm": 1 + }, + "3": { + "device_id": 0, + "setpoint": 1000, + "is_rpm": 1 + }, + "4": { + "device_id": 0, + "setpoint": 1000, + "is_rpm": 1 + }, + "5": { + "device_id": 0, + "setpoint": 1000, + "is_rpm": 1 + }, + "6": { + "device_id": 0, + "setpoint": 1000, + "is_rpm": 1 + }, + "7": { + "device_id": 0, + "setpoint": 1000, + "is_rpm": 1 + } + } + }, + "1": { + "data": 131, + "type": 2, + "state": { + "name": "Jets Pump", + "value": 0 + }, + "watts_now": { + "name": "Jets Pump Watts Now", + "value": 0, + "unit": "W", + "device_type": "power", + "state_type": "measurement" + }, + "rpm_now": { + "name": "Jets Pump RPM Now", + "value": 0, + "unit": "rpm", + "state_type": "measurement" + }, + "unknown_at_offset_16": 0, + "gpm_now": { + "name": "Jets Pump GPM Now", + "value": 0, + "unit": "gpm", + "state_type": "measurement" + }, + "unknown_at_offset_24": 255, + "preset": { + "0": { + "device_id": 3, + "setpoint": 2970, + "is_rpm": 1 + }, + "1": { + "device_id": 0, + "setpoint": 1000, + "is_rpm": 1 + }, + "2": { + "device_id": 0, + "setpoint": 1000, + "is_rpm": 1 + }, + "3": { + "device_id": 0, + "setpoint": 1000, + "is_rpm": 1 + }, + "4": { + "device_id": 0, + "setpoint": 1000, + "is_rpm": 1 + }, + "5": { + "device_id": 0, + "setpoint": 1000, + "is_rpm": 1 + }, + "6": { + "device_id": 0, + "setpoint": 1000, + "is_rpm": 1 + }, + "7": { + "device_id": 0, + "setpoint": 1000, + "is_rpm": 1 + } + } + }, + "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": 86, + "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": 85, + "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": 102, + "unit": "\u00b0F", + "device_type": "temperature" + }, + "cool_setpoint": { + "name": "Spa Cool Set Point", + "value": 91, + "unit": "\u00b0F", + "device_type": "temperature" + }, + "heat_mode": { + "name": "Spa Heat Mode", + "value": 3, + "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": 0, + "state_type": "measurement" + }, + "orp_supply_level": { + "name": "ORP Supply Level", + "value": 0, + "state_type": "measurement" + }, + "saturation": { + "name": "Saturation Index", + "value": 0.0, + "unit": "lsi", + "state_type": "measurement" + }, + "ph_probe_water_temp": { + "name": "pH Probe Water Temperature", + "value": 0, + "unit": "\u00b0F", + "device_type": "temperature", + "state_type": "measurement" + } + }, + "configuration": { + "ph_setpoint": { + "name": "pH Setpoint", + "value": 0.0, + "unit": "pH" + }, + "orp_setpoint": { + "name": "ORP Setpoint", + "value": 0, + "unit": "mV" + }, + "calcium_harness": { + "name": "Calcium Hardness", + "value": 0, + "unit": "ppm" + }, + "cya": { + "name": "Cyanuric Acid", + "value": 0, + "unit": "ppm" + }, + "total_alkalinity": { + "name": "Total Alkalinity", + "value": 0, + "unit": "ppm" + }, + "salt_tds_ppm": { + "name": "Salt/TDS", + "value": 0, + "unit": "ppm" + }, + "probe_is_celsius": 0, + "flags": 0 + }, + "dose_status": { + "ph_last_dose_time": { + "name": "Last pH Dose Time", + "value": 0, + "unit": "sec", + "device_type": "duration", + "state_type": "total_increasing" + }, + "orp_last_dose_time": { + "name": "Last ORP Dose Time", + "value": 0, + "unit": "sec", + "device_type": "duration", + "state_type": "total_increasing" + }, + "ph_last_dose_volume": { + "name": "Last pH Dose Volume", + "value": 0, + "unit": "mL", + "device_type": "volume", + "state_type": "total_increasing" + }, + "orp_last_dose_volume": { + "name": "Last ORP Dose Volume", + "value": 0, + "unit": "mL", + "device_type": "volume", + "state_type": "total_increasing" + }, + "flags": 0, + "ph_dosing_state": { + "name": "pH Dosing State", + "value": 0, + "device_type": "enum", + "enum_options": ["Dosing", "Mixing", "Monitoring"] + }, + "orp_dosing_state": { + "name": "ORP Dosing State", + "value": 0, + "device_type": "enum", + "enum_options": ["Dosing", "Mixing", "Monitoring"] + } + }, + "alarm": { + "flags": 0, + "flow_alarm": { + "name": "Flow Alarm", + "value": 0, + "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": "0.000" + }, + "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": 1 + }, + "salt_ppm": { + "name": "Chlorinator Salt", + "value": 0, + "unit": "ppm", + "state_type": "measurement" + } + }, + "configuration": { + "pool_setpoint": { + "name": "Pool Chlorinator Setpoint", + "value": 50, + "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_full_no_salt_ppm.json b/tests/components/screenlogic/fixtures/data_full_no_salt_ppm.json new file mode 100644 index 00000000000..d17d0e41170 --- /dev/null +++ b/tests/components/screenlogic/fixtures/data_full_no_salt_ppm.json @@ -0,0 +1,859 @@ +{ + "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": 60, + "list": ["CHLORINATOR", "INTELLIBRITE", "INTELLIFLO_0", "INTELLIFLO_1"] + }, + "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": 1, + "sensor": { + "state": { + "name": "Chlorinator", + "value": 0 + } + }, + "configuration": { + "pool_setpoint": { + "name": "Pool Chlorinator Setpoint", + "value": 50, + "unit": "%", + "min_setpoint": 0, + "max_setpoint": 100, + "step": 5, + "body_type": 0 + }, + "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 00000000000..40f7dbe4ad5 --- /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 00000000000..335c98db0ae --- /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/fixtures/data_missing_values_chem_chlor.json b/tests/components/screenlogic/fixtures/data_missing_values_chem_chlor.json new file mode 100644 index 00000000000..c30ee690f8a --- /dev/null +++ b/tests/components/screenlogic/fixtures/data_missing_values_chem_chlor.json @@ -0,0 +1,849 @@ +{ + "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": 32828, + "list": [ + "CHLORINATOR", + "INTELLIBRITE", + "INTELLIFLO_0", + "INTELLIFLO_1", + "INTELLICHEM" + ] + }, + "sensor": { + "state": { + "name": "Controller State", + "value": 1, + "device_type": "enum", + "enum_options": ["Unknown", "Ready", "Sync", "Service"] + }, + "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" + }, + "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, + "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": { + "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": 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": 50, + "unit": "%", + "min_setpoint": 0, + "max_setpoint": 100, + "step": 5, + "body_type": 0 + }, + "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 00000000000..05320c147e5 --- /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 f2c39e05b48..14488c66564 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 00000000000..ead064f7d93 --- /dev/null +++ b/tests/components/screenlogic/test_data.py @@ -0,0 +1,70 @@ +"""Tests for ScreenLogic integration data processing.""" +from unittest.mock import DEFAULT, patch + +from screenlogicpy import ScreenLogicGateway + +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 . 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 diff --git a/tests/components/screenlogic/test_diagnostics.py b/tests/components/screenlogic/test_diagnostics.py new file mode 100644 index 00000000000..dcbca954730 --- /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 00000000000..cf0a7ef3f38 --- /dev/null +++ b/tests/components/screenlogic/test_init.py @@ -0,0 +1,279 @@ +"""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.number import DOMAIN as NUMBER_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, + DATA_MISSING_VALUES_CHEM_CHLOR, + 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, + ), + EntityMigrationData( + "Pump Sensor Missing Index", + "currentWatts", + "Pump Sensor Missing Index", + "currentWatts", + 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 + + +async def test_platform_setup( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test setup for platforms that define expected data.""" + stub_connect = lambda *args, **kwargs: stub_async_connect( + DATA_MISSING_VALUES_CHEM_CHLOR, *args, **kwargs + ) + + device_prefix = slugify(MOCK_ADAPTER_NAME) + + tested_entity_ids = [ + f"{BINARY_SENSOR_DOMAIN}.{device_prefix}_active_alert", + f"{SENSOR_DOMAIN}.{device_prefix}_air_temperature", + f"{NUMBER_DOMAIN}.{device_prefix}_pool_chlorinator_setpoint", + ] + + mock_config_entry.add_to_hass(hass) + + with patch( + GATEWAY_DISCOVERY_IMPORT_PATH, + return_value={}, + ), patch.multiple( + ScreenLogicGateway, + async_connect=stub_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() + + for entity_id in tested_entity_ids: + assert hass.states.get(entity_id) is not None diff --git a/tests/components/search/test_init.py b/tests/components/search/test_init.py index 40ec9c22afe..ebf70a6239c 100644 --- a/tests/components/search/test_init.py +++ b/tests/components/search/test_init.py @@ -6,7 +6,6 @@ from homeassistant.core import HomeAssistant 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/sensibo/fixtures/data.json b/tests/components/sensibo/fixtures/data.json index 8be6d1e173a..96657df50d3 100644 --- a/tests/components/sensibo/fixtures/data.json +++ b/tests/components/sensibo/fixtures/data.json @@ -608,7 +608,7 @@ "isGeofenceOnExitEnabled": false, "isClimateReactGeofenceOnExitEnabled": false, "isMotionGeofenceOnExitEnabled": false, - "serial": "0987654321", + "serial": "0987654329", "sensorsCalibration": { "temperature": 0.0, "humidity": 0.0 @@ -699,7 +699,7 @@ "ssid": "Sensibo-09876", "password": null }, - "macAddress": "00:01:00:01:00:01", + "macAddress": "00:03:00:03:00:03", "autoOffMinutes": null, "autoOffEnabled": false, "antiMoldTimer": null, diff --git a/tests/components/sensibo/snapshots/test_climate.ambr b/tests/components/sensibo/snapshots/test_climate.ambr new file mode 100644 index 00000000000..0a5a9d78b1b --- /dev/null +++ b/tests/components/sensibo/snapshots/test_climate.ambr @@ -0,0 +1,33 @@ +# serializer version: 1 +# name: test_climate + ReadOnlyDict({ + 'current_humidity': 32.9, + 'current_temperature': 21.2, + 'fan_mode': 'high', + 'fan_modes': list([ + 'quiet', + 'low', + 'medium', + ]), + 'friendly_name': 'Hallway', + 'hvac_modes': list([ + , + , + , + , + , + , + ]), + 'max_temp': 20, + 'min_temp': 10, + 'supported_features': , + 'swing_mode': 'stopped', + 'swing_modes': list([ + 'stopped', + 'fixedtop', + 'fixedmiddletop', + ]), + 'target_temp_step': 1, + 'temperature': 25, + }) +# --- diff --git a/tests/components/sensibo/snapshots/test_sensor.ambr b/tests/components/sensibo/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..4522071049d --- /dev/null +++ b/tests/components/sensibo/snapshots/test_sensor.ambr @@ -0,0 +1,26 @@ +# serializer version: 1 +# name: test_sensor + ReadOnlyDict({ + 'device_class': 'pm25', + 'friendly_name': 'Kitchen PM2.5', + 'icon': 'mdi:air-filter', + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_sensor.1 + ReadOnlyDict({ + 'device_class': 'temperature', + 'fanlevel': 'low', + 'friendly_name': 'Hallway Climate React low temperature threshold', + 'horizontalswing': 'stopped', + 'light': 'on', + 'mode': 'heat', + 'on': True, + 'state_class': , + 'swing': 'stopped', + 'targettemperature': 21, + 'temperatureunit': 'c', + 'unit_of_measurement': , + }) +# --- diff --git a/tests/components/sensibo/test_climate.py b/tests/components/sensibo/test_climate.py index 688a373b8f0..530034720f2 100644 --- a/tests/components/sensibo/test_climate.py +++ b/tests/components/sensibo/test_climate.py @@ -6,6 +6,7 @@ from unittest.mock import patch from pysensibo.model import SensiboData import pytest +from syrupy.assertion import SnapshotAssertion from voluptuous import MultipleInvalid from homeassistant.components.climate import ( @@ -80,6 +81,7 @@ async def test_climate( caplog: pytest.LogCaptureFixture, get_data: SensiboData, load_int: ConfigEntry, + snapshot: SnapshotAssertion, ) -> None: """Test the Sensibo climate.""" @@ -88,38 +90,18 @@ async def test_climate( state3 = hass.states.get("climate.bedroom") assert state1.state == "heat" - assert state1.attributes == { - "hvac_modes": [ - "heat_cool", - "cool", - "dry", - "fan_only", - "heat", - "off", - ], - "min_temp": 10, - "max_temp": 20, - "target_temp_step": 1, - "fan_modes": ["low", "medium", "quiet"], - "swing_modes": ["fixedmiddletop", "fixedtop", "stopped"], - "current_temperature": 21.2, - "temperature": 25, - "current_humidity": 32.9, - "fan_mode": "high", - "swing_mode": "stopped", - "friendly_name": "Hallway", - "supported_features": 41, - } + assert state1.attributes == snapshot assert state2.state == "off" - assert not state3 + assert state3 + assert state3.state == "off" found_log = False logs = caplog.get_records("setup") for log in logs: if ( log.message - == "Device Bedroom not correctly registered with Sensibo cloud. Skipping device" + == "Device Bedroom not correctly registered with remote on Sensibo cloud." ): found_log = True break diff --git a/tests/components/sensibo/test_sensor.py b/tests/components/sensibo/test_sensor.py index 24dbdef1fe3..b3089c37e68 100644 --- a/tests/components/sensibo/test_sensor.py +++ b/tests/components/sensibo/test_sensor.py @@ -6,6 +6,7 @@ from unittest.mock import patch from pysensibo.model import SensiboData import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -18,8 +19,9 @@ async def test_sensor( hass: HomeAssistant, entity_registry_enabled_by_default: None, load_int: ConfigEntry, - monkeypatch: pytest.pytest.MonkeyPatch, + monkeypatch: pytest.MonkeyPatch, get_data: SensiboData, + snapshot: SnapshotAssertion, ) -> None: """Test the Sensibo sensor.""" @@ -31,27 +33,8 @@ async def test_sensor( assert state2.state == "1" assert state3.state == "n" assert state4.state == "0.0" - assert state2.attributes == { - "state_class": "measurement", - "unit_of_measurement": "µg/m³", - "device_class": "pm25", - "icon": "mdi:air-filter", - "friendly_name": "Kitchen PM2.5", - } - assert state4.attributes == { - "device_class": "temperature", - "friendly_name": "Hallway Climate React low temperature threshold", - "state_class": "measurement", - "unit_of_measurement": "°C", - "on": True, - "targettemperature": 21, - "temperatureunit": "c", - "mode": "heat", - "fanlevel": "low", - "swing": "stopped", - "horizontalswing": "stopped", - "light": "on", - } + assert state2.attributes == snapshot + assert state4.attributes == snapshot monkeypatch.setattr(get_data.parsed["AAZZAAZZ"], "pm25", 2) diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index 6ca26433056..01dfb9b3649 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -13,6 +13,7 @@ from homeassistant.components.sensor import ( DEVICE_CLASS_STATE_CLASSES, DEVICE_CLASS_UNITS, DOMAIN as SENSOR_DOMAIN, + NON_NUMERIC_DEVICE_CLASSES, SensorDeviceClass, SensorEntity, SensorEntityDescription, @@ -165,7 +166,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") @@ -2483,3 +2484,15 @@ def test_async_rounded_state_registered_entity_with_display_precision( hass.states.async_set(entity_id, "-0.0") state = hass.states.get(entity_id) assert async_rounded_state(hass, entity_id, state) == "0.0000" + + +def test_device_class_units_state_classes(hass: HomeAssistant) -> None: + """Test all numeric device classes have unit and state class.""" + # DEVICE_CLASS_UNITS should include all device classes except: + # - SensorDeviceClass.MONETARY + # - Device classes enumerated in NON_NUMERIC_DEVICE_CLASSES + assert set(DEVICE_CLASS_UNITS) == set( + SensorDeviceClass + ) - NON_NUMERIC_DEVICE_CLASSES - {SensorDeviceClass.MONETARY} + # DEVICE_CLASS_STATE_CLASSES should include all device classes + assert set(DEVICE_CLASS_STATE_CLASSES) == set(SensorDeviceClass) diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index 797673265a6..438ca9b5ace 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -144,7 +144,7 @@ MOCK_BLOCKS = [ ] MOCK_CONFIG = { - "input:0": {"id": 0, "type": "button"}, + "input:0": {"id": 0, "name": "Test name input 0", "type": "button"}, "light:0": {"name": "test light_0"}, "switch:0": {"name": "test switch_0"}, "cover:0": {"name": "test cover_0"}, @@ -191,6 +191,7 @@ MOCK_STATUS_COAP = { 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 @@ MOCK_STATUS_RPC = { "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 7a29d7b1a42..073847e0308 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.common import MockConfigEntry 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 @@ DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( 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 00000000000..b7824d8d7ac --- /dev/null +++ b/tests/components/shelly/test_event.py @@ -0,0 +1,114 @@ +"""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 + +DEVICE_BLOCK_ID = 4 + + +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 + + +async def test_block_event(hass: HomeAssistant, monkeypatch, mock_block_device) -> None: + """Test block device event.""" + await init_integration(hass, 1) + entity_id = "event.test_name_channel_1" + 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(["single", "long"]) + 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-relay_0-1" + + monkeypatch.setattr( + mock_block_device.blocks[DEVICE_BLOCK_ID], + "sensor_ids", + {"inputEvent": "L", "inputEventCnt": 0}, + ) + monkeypatch.setattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "inputEvent", "L") + mock_block_device.mock_update() + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.attributes.get(ATTR_EVENT_TYPE) == "long" + + +async def test_block_event_shix3_1(hass: HomeAssistant, mock_block_device) -> None: + """Test block device event for SHIX3-1.""" + await init_integration(hass, 1, model="SHIX3-1") + entity_id = "event.test_name_channel_1" + + state = hass.states.get(entity_id) + assert state + assert state.attributes.get(ATTR_EVENT_TYPES) == unordered( + ["double", "long", "long_single", "single", "single_long", "triple"] + ) diff --git a/tests/components/shelly/test_logbook.py b/tests/components/shelly/test_logbook.py index 07db4776166..b73a5b552c5 100644 --- a/tests/components/shelly/test_logbook.py +++ b/tests/components/shelly/test_logbook.py @@ -108,7 +108,7 @@ async def test_humanify_shelly_click_event_rpc_device( assert event1["domain"] == DOMAIN assert ( event1["message"] - == "'single_push' click event for test switch_0 Input was fired" + == "'single_push' click event for Test name input 0 Input was fired" ) assert event2["name"] == "Shelly" diff --git a/tests/components/shelly/test_sensor.py b/tests/components/shelly/test_sensor.py index 892d06ad626..a738113f18f 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 1ff2ac99814..454afb73ce1 100644 --- a/tests/components/shelly/test_update.py +++ b/tests/components/shelly/test_update.py @@ -29,6 +29,7 @@ from homeassistant.helpers.entity_registry import async_get 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 1bf660deb2a..3d273ff3059 100644 --- a/tests/components/shelly/test_utils.py +++ b/tests/components/shelly/test_utils.py @@ -206,8 +206,8 @@ async def test_get_block_input_triggers(mock_block_device, monkeypatch) -> None: async def test_get_rpc_channel_name(mock_rpc_device) -> None: """Test get RPC channel name.""" - assert get_rpc_channel_name(mock_rpc_device, "input:0") == "test switch_0" - assert get_rpc_channel_name(mock_rpc_device, "input:3") == "Test name switch_3" + assert get_rpc_channel_name(mock_rpc_device, "input:0") == "Test name input 0" + assert get_rpc_channel_name(mock_rpc_device, "input:3") == "Test name input_3" async def test_get_rpc_input_triggers(mock_rpc_device, monkeypatch) -> None: diff --git a/tests/components/slack/test_notify.py b/tests/components/slack/test_notify.py index 232f78e97e4..6c90ad8cd39 100644 --- a/tests/components/slack/test_notify.py +++ b/tests/components/slack/test_notify.py @@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, Mock 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 a6e8f8ae45c..f6f5ab66708 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 aca30c8eac7..86a21c754ed 100644 --- a/tests/components/smtp/test_notify.py +++ b/tests/components/smtp/test_notify.py @@ -6,7 +6,7 @@ import pytest 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 bab2b89009f..cb912af1cf6 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 @@ class SonosMockEvent: 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 270bdec4b52..2fd8ad110df 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 68f884ca006..896202355ac 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 3324b92d8bd..0dab08eddef 100644 --- a/tests/components/speedtestdotnet/conftest.py +++ b/tests/components/speedtestdotnet/conftest.py @@ -3,14 +3,12 @@ from unittest.mock import patch 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 da19fd85dd3..5083f56a8e2 100644 --- a/tests/components/speedtestdotnet/test_init.py +++ b/tests/components/speedtestdotnet/test_init.py @@ -18,25 +18,6 @@ import homeassistant.util.dt as dt_util 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 887f0ba0491..d15e9fb92f4 100644 --- a/tests/components/speedtestdotnet/test_sensor.py +++ b/tests/components/speedtestdotnet/test_sensor.py @@ -3,11 +3,11 @@ from unittest.mock import MagicMock 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 46d9741684a..7940964d68f 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.test_util.aiohttp import AiohttpClientMocker 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 3d0e2768ade..cb988d3f2d4 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/srp_energy/test_sensor.py b/tests/components/srp_energy/test_sensor.py index 1ae213e4bf1..32d2d971d2c 100644 --- a/tests/components/srp_energy/test_sensor.py +++ b/tests/components/srp_energy/test_sensor.py @@ -9,7 +9,6 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( ATTR_ATTRIBUTION, ATTR_DEVICE_CLASS, - ATTR_ICON, ATTR_UNIT_OF_MEASUREMENT, UnitOfEnergy, ) @@ -29,7 +28,7 @@ async def test_loading_sensors(hass: HomeAssistant, init_integration) -> None: async def test_srp_entity(hass: HomeAssistant, init_integration) -> None: """Test the SrpEntity.""" - usage_state = hass.states.get("sensor.home_energy_usage") + usage_state = hass.states.get("sensor.srp_energy_energy_usage") assert usage_state.state == "150.8" # Validate attributions @@ -43,7 +42,6 @@ async def test_srp_entity(hass: HomeAssistant, init_integration) -> None: ) assert usage_state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY - assert usage_state.attributes.get(ATTR_ICON) == "mdi:flash" async def test_srp_entity_update_failed( diff --git a/tests/components/ssdp/test_init.py b/tests/components/ssdp/test_init.py index ed5241a42ad..324136c011b 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 0c625a8dec1..8372e5d5e61 100644 --- a/tests/components/stream/test_init.py +++ b/tests/components/stream/test_init.py @@ -5,6 +5,7 @@ import av import pytest from homeassistant.components.stream import __name__ as stream_name +from homeassistant.const import EVENT_LOGGING_CHANGED 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/sun/test_sensor.py b/tests/components/sun/test_sensor.py index 38453569269..6559cc3f7e9 100644 --- a/tests/components/sun/test_sensor.py +++ b/tests/components/sun/test_sensor.py @@ -3,7 +3,8 @@ from datetime import datetime, timedelta from astral import LocationInfo import astral.sun -from freezegun import freeze_time +from freezegun.api import FrozenDateTimeFactory +import pytest from homeassistant.components import sun from homeassistant.const import EntityCategory @@ -13,12 +14,15 @@ from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -async def test_setting_rising(hass: HomeAssistant) -> None: +async def test_setting_rising( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + entity_registry_enabled_by_default: None, +) -> None: """Test retrieving sun setting and rising.""" utc_now = datetime(2016, 11, 1, 8, 0, 0, tzinfo=dt_util.UTC) - with freeze_time(utc_now): - await async_setup_component(hass, sun.DOMAIN, {sun.DOMAIN: {}}) - + freezer.move_to(utc_now) + await async_setup_component(hass, sun.DOMAIN, {sun.DOMAIN: {}}) await hass.async_block_till_done() utc_today = utc_now.date() @@ -81,6 +85,9 @@ async def test_setting_rising(hass: HomeAssistant) -> None: break mod += 1 + expected_solar_elevation = astral.sun.elevation(location.observer, utc_now) + expected_solar_azimuth = astral.sun.azimuth(location.observer, utc_now) + state1 = hass.states.get("sensor.sun_next_dawn") state2 = hass.states.get("sensor.sun_next_dusk") state3 = hass.states.get("sensor.sun_next_midnight") @@ -93,6 +100,14 @@ async def test_setting_rising(hass: HomeAssistant) -> None: assert next_noon.replace(microsecond=0) == dt_util.parse_datetime(state4.state) assert next_rising.replace(microsecond=0) == dt_util.parse_datetime(state5.state) assert next_setting.replace(microsecond=0) == dt_util.parse_datetime(state6.state) + solar_elevation_state = hass.states.get("sensor.sun_solar_elevation") + assert float(solar_elevation_state.state) == pytest.approx( + expected_solar_elevation, 0.1 + ) + solar_azimuth_state = hass.states.get("sensor.sun_solar_azimuth") + assert float(solar_azimuth_state.state) == pytest.approx( + expected_solar_azimuth, 0.1 + ) entry_ids = hass.config_entries.async_entries("sun") @@ -102,3 +117,24 @@ async def test_setting_rising(hass: HomeAssistant) -> None: assert entity assert entity.entity_category is EntityCategory.DIAGNOSTIC assert entity.unique_id == f"{entry_ids[0].entry_id}-next_dawn" + + freezer.tick(timedelta(hours=24)) + # Block once for Sun to update + await hass.async_block_till_done() + # Block another time for the sensors to update + await hass.async_block_till_done() + + # Make sure all the signals work + assert state1.state != hass.states.get("sensor.sun_next_dawn").state + assert state2.state != hass.states.get("sensor.sun_next_dusk").state + assert state3.state != hass.states.get("sensor.sun_next_midnight").state + assert state4.state != hass.states.get("sensor.sun_next_noon").state + assert state5.state != hass.states.get("sensor.sun_next_rising").state + assert state6.state != hass.states.get("sensor.sun_next_setting").state + assert ( + solar_elevation_state.state + != hass.states.get("sensor.sun_solar_elevation").state + ) + assert ( + solar_azimuth_state.state != hass.states.get("sensor.sun_solar_azimuth").state + ) diff --git a/tests/components/switchbot_cloud/__init__.py b/tests/components/switchbot_cloud/__init__.py new file mode 100644 index 00000000000..72d23c837ac --- /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 00000000000..b96d7638797 --- /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 00000000000..6fdf8fecdb7 --- /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 00000000000..48f0021bdb4 --- /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 ef4dee7c597..4d4ba583169 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 d01ed9a3ff8..56afc87c3bb 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_INPUT = { } 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 = zeroconf.ZeroconfServiceInfo( ) 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 bd861ac7668..1357d9e5e9e 100644 --- a/tests/components/system_log/test_init.py +++ b/tests/components/system_log/test_init.py @@ -4,6 +4,7 @@ from __future__ import annotations 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 @@ class WatchLogErrorHandler(system_log.LogErrorHandler): 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 @@ async def test_raise_during_log_capture( 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 dcbb33b587e..c4a39914e53 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/template/snapshots/test_binary_sensor.ambr b/tests/components/template/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..2529021971a --- /dev/null +++ b/tests/components/template/snapshots/test_binary_sensor.ambr @@ -0,0 +1,26 @@ +# serializer version: 1 +# name: test_setup_config_entry[config_entry_extra_options0] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'My template', + }), + 'context': , + 'entity_id': 'binary_sensor.my_template', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_setup_config_entry[config_entry_extra_options1] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'My template', + }), + 'context': , + 'entity_id': 'binary_sensor.my_template', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/template/snapshots/test_sensor.ambr b/tests/components/template/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..7959959dfa9 --- /dev/null +++ b/tests/components/template/snapshots/test_sensor.ambr @@ -0,0 +1,28 @@ +# serializer version: 1 +# name: test_setup_config_entry[config_entry_extra_options0] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'My template', + }), + 'context': , + 'entity_id': 'sensor.my_template', + 'last_changed': , + 'last_updated': , + 'state': '30.0', + }) +# --- +# name: test_setup_config_entry[config_entry_extra_options1] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'My template', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.my_template', + 'last_changed': , + 'last_updated': , + 'state': '30.0', + }) +# --- diff --git a/tests/components/template/test_binary_sensor.py b/tests/components/template/test_binary_sensor.py index e43163f66fc..01c0f005716 100644 --- a/tests/components/template/test_binary_sensor.py +++ b/tests/components/template/test_binary_sensor.py @@ -5,6 +5,7 @@ from unittest.mock import patch from freezegun.api import FrozenDateTimeFactory import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant import setup from homeassistant.components import binary_sensor, template @@ -23,6 +24,7 @@ from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util from tests.common import ( + MockConfigEntry, assert_setup_component, async_fire_time_changed, mock_restore_cache, @@ -123,6 +125,55 @@ async def test_setup(hass: HomeAssistant, start_ha, entity_id) -> None: assert state.attributes["device_class"] == "motion" +@pytest.mark.parametrize( + "config_entry_extra_options", + [ + {}, + {"device_class": "battery"}, + ], +) +async def test_setup_config_entry( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + config_entry_extra_options: dict[str, str], +) -> None: + """Test the config flow.""" + state_template = ( + "{{ states('binary_sensor.one') == 'on' or " + " states('binary_sensor.two') == 'on' }}" + ) + input_entities = ["one", "two"] + input_states = {"one": "on", "two": "off"} + template_type = binary_sensor.DOMAIN + + for input_entity in input_entities: + hass.states.async_set( + f"{template_type}.{input_entity}", + input_states[input_entity], + {}, + ) + + template_config_entry = MockConfigEntry( + data={}, + domain=template.DOMAIN, + options={ + "name": "My template", + "state": state_template, + "template_type": template_type, + } + | config_entry_extra_options, + title="My template", + ) + template_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(template_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(f"{template_type}.my_template") + assert state is not None + assert state == snapshot + + @pytest.mark.parametrize("count", [0]) @pytest.mark.parametrize( ("config", "domain"), diff --git a/tests/components/template/test_sensor.py b/tests/components/template/test_sensor.py index 4010bb34d2d..0ca666d22f1 100644 --- a/tests/components/template/test_sensor.py +++ b/tests/components/template/test_sensor.py @@ -4,9 +4,10 @@ from datetime import timedelta from unittest.mock import ANY, patch import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.bootstrap import async_from_config_dict -from homeassistant.components import sensor +from homeassistant.components import sensor, template from homeassistant.const import ( ATTR_ENTITY_PICTURE, ATTR_ICON, @@ -25,6 +26,7 @@ from homeassistant.setup import ATTR_COMPONENT, async_setup_component import homeassistant.util.dt as dt_util from tests.common import ( + MockConfigEntry, assert_setup_component, async_fire_time_changed, mock_restore_cache_with_extra_data, @@ -33,6 +35,56 @@ from tests.common import ( TEST_NAME = "sensor.test_template_sensor" +@pytest.mark.parametrize( + "config_entry_extra_options", + [ + {}, + { + "device_class": "battery", + "state_class": "measurement", + "unit_of_measurement": "%", + }, + ], +) +async def test_setup_config_entry( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + config_entry_extra_options: dict[str, str], +) -> None: + """Test the config flow.""" + state_template = "{{ float(states('sensor.one')) + float(states('sensor.two')) }}" + input_entities = ["one", "two"] + input_states = {"one": "10", "two": "20"} + template_type = sensor.DOMAIN + + for input_entity in input_entities: + hass.states.async_set( + f"{template_type}.{input_entity}", + input_states[input_entity], + {}, + ) + + template_config_entry = MockConfigEntry( + data={}, + domain=template.DOMAIN, + options={ + "name": "My template", + "state": state_template, + "template_type": template_type, + } + | config_entry_extra_options, + title="My template", + ) + template_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(template_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(f"{template_type}.my_template") + assert state is not None + assert state == snapshot + + @pytest.mark.parametrize(("count", "domain"), [(1, sensor.DOMAIN)]) @pytest.mark.parametrize( "config", diff --git a/tests/components/template/test_weather.py b/tests/components/template/test_weather.py index 97965a5643e..7ca3d11b099 100644 --- a/tests/components/template/test_weather.py +++ b/tests/components/template/test_weather.py @@ -1,4 +1,6 @@ """The tests for the Template Weather platform.""" +from typing import Any + import pytest from homeassistant.components.weather import ( @@ -18,8 +20,18 @@ from homeassistant.components.weather import ( SERVICE_GET_FORECAST, Forecast, ) -from homeassistant.const import ATTR_ATTRIBUTION -from homeassistant.core import HomeAssistant +from homeassistant.const import ATTR_ATTRIBUTION, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import Context, HomeAssistant, State +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.restore_state import STORAGE_KEY as RESTORE_STATE_KEY +from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util + +from tests.common import ( + assert_setup_component, + async_mock_restore_state_shutdown_restart, + mock_restore_cache_with_extra_data, +) @pytest.mark.parametrize(("count", "domain"), [(1, WEATHER_DOMAIN)]) @@ -493,3 +505,457 @@ async def test_forecast_format_error( return_response=True, ) assert "Forecast in list is not a dict, see Weather documentation" in caplog.text + + +SAVED_EXTRA_DATA = { + "last_apparent_temperature": None, + "last_cloud_coverage": None, + "last_dew_point": None, + "last_forecast": None, + "last_humidity": 10, + "last_ozone": None, + "last_pressure": None, + "last_temperature": 20, + "last_visibility": None, + "last_wind_bearing": None, + "last_wind_gust_speed": None, + "last_wind_speed": None, +} + +SAVED_EXTRA_DATA_WITH_FUTURE_KEY = { + "last_apparent_temperature": None, + "last_cloud_coverage": None, + "last_dew_point": None, + "last_forecast": None, + "last_humidity": 10, + "last_ozone": None, + "last_pressure": None, + "last_temperature": 20, + "last_visibility": None, + "last_wind_bearing": None, + "last_wind_gust_speed": None, + "last_wind_speed": None, + "some_key_added_in_the_future": 123, +} + + +@pytest.mark.parametrize(("count", "domain"), [(1, "template")]) +@pytest.mark.parametrize( + "config", + [ + { + "template": { + "trigger": {"platform": "event", "event_type": "test_event"}, + "weather": { + "name": "test", + "condition_template": "{{ trigger.event.data.condition }}", + "temperature_template": "{{ trigger.event.data.temperature | float }}", + "temperature_unit": "°C", + "humidity_template": "{{ trigger.event.data.humidity | float }}", + }, + }, + }, + ], +) +@pytest.mark.parametrize( + ("saved_state", "saved_extra_data", "initial_state"), + [ + ("sunny", SAVED_EXTRA_DATA, "sunny"), + ("sunny", SAVED_EXTRA_DATA_WITH_FUTURE_KEY, "sunny"), + (STATE_UNAVAILABLE, SAVED_EXTRA_DATA, STATE_UNKNOWN), + (STATE_UNKNOWN, SAVED_EXTRA_DATA, STATE_UNKNOWN), + ], +) +async def test_trigger_entity_restore_state( + hass: HomeAssistant, + count: int, + domain: str, + config: dict, + saved_state: str, + saved_extra_data: dict | None, + initial_state: str, +) -> None: + """Test restoring trigger template weather.""" + + restored_attributes = { # These should be ignored + "temperature": -10, + "humidity": 50, + } + + fake_state = State( + "weather.test", + saved_state, + restored_attributes, + ) + mock_restore_cache_with_extra_data(hass, ((fake_state, saved_extra_data),)) + with assert_setup_component(count, domain): + assert await async_setup_component( + hass, + domain, + config, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + state = hass.states.get("weather.test") + assert state.state == initial_state + + hass.bus.async_fire( + "test_event", {"condition": "cloudy", "temperature": 15, "humidity": 25} + ) + await hass.async_block_till_done() + state = hass.states.get("weather.test") + + state = hass.states.get("weather.test") + assert state.state == "cloudy" + assert state.attributes["temperature"] == 15.0 + assert state.attributes["humidity"] == 25.0 + + +@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.temperature + 1 }}" + }, + }, + ], + "weather": [ + { + "name": "Hello Name", + "condition_template": "sunny", + "temperature_unit": "°C", + "humidity_template": "{{ 20 }}", + "temperature_template": "{{ 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("weather.hello_name") + assert state is not None + assert state.state == STATE_UNKNOWN + + context = Context() + hass.bus.async_fire("test_event", {"temperature": 1}, context=context) + await hass.async_block_till_done() + + state = hass.states.get("weather.hello_name") + assert state.state == "sunny" + assert state.attributes["temperature"] == 3.0 + assert state.context is context + + +@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.information + 1 }}", + "var_forecast_daily": "{{ trigger.event.data.forecast_daily }}", + "var_forecast_hourly": "{{ trigger.event.data.forecast_hourly }}", + "var_forecast_twice_daily": "{{ trigger.event.data.forecast_twice_daily }}", + }, + }, + ], + "weather": [ + { + "name": "Test", + "condition_template": "sunny", + "precipitation_unit": "mm", + "pressure_unit": "hPa", + "visibility_unit": "km", + "wind_speed_unit": "km/h", + "temperature_unit": "°C", + "temperature_template": "{{ my_variable + 1 }}", + "humidity_template": "{{ my_variable + 1 }}", + "wind_speed_template": "{{ my_variable + 1 }}", + "wind_bearing_template": "{{ my_variable + 1 }}", + "ozone_template": "{{ my_variable + 1 }}", + "visibility_template": "{{ my_variable + 1 }}", + "pressure_template": "{{ my_variable + 1 }}", + "wind_gust_speed_template": "{{ my_variable + 1 }}", + "cloud_coverage_template": "{{ my_variable + 1 }}", + "dew_point_template": "{{ my_variable + 1 }}", + "apparent_temperature_template": "{{ my_variable + 1 }}", + "forecast_template": "{{ var_forecast_daily }}", + "forecast_daily_template": "{{ var_forecast_daily }}", + "forecast_hourly_template": "{{ var_forecast_hourly }}", + "forecast_twice_daily_template": "{{ var_forecast_twice_daily }}", + } + ], + }, + ], + }, + ], +) +async def test_trigger_weather_services( + hass: HomeAssistant, start_ha, entity_registry: er.EntityRegistry +) -> None: + """Test trigger weather entity with services.""" + state = hass.states.get("weather.test") + assert state is not None + assert state.state == STATE_UNKNOWN + + context = Context() + now = dt_util.now().isoformat() + hass.bus.async_fire( + "test_event", + { + "information": 1, + "forecast_daily": [ + { + "datetime": now, + "condition": "sunny", + "precipitation": 20, + "temperature": 20, + "templow": 15, + } + ], + "forecast_hourly": [ + { + "datetime": now, + "condition": "sunny", + "precipitation": 20, + "temperature": 20, + "templow": 15, + } + ], + "forecast_twice_daily": [ + { + "datetime": now, + "condition": "sunny", + "precipitation": 20, + "temperature": 20, + "templow": 15, + "is_daytime": True, + } + ], + }, + context=context, + ) + await hass.async_block_till_done() + + state = hass.states.get("weather.test") + assert state.state == "sunny" + assert state.attributes["temperature"] == 3.0 + assert state.attributes["humidity"] == 3.0 + assert state.attributes["wind_speed"] == 3.0 + assert state.attributes["wind_bearing"] == 3.0 + assert state.attributes["ozone"] == 3.0 + assert state.attributes["visibility"] == 3.0 + assert state.attributes["pressure"] == 3.0 + assert state.attributes["wind_gust_speed"] == 3.0 + assert state.attributes["cloud_coverage"] == 3.0 + assert state.attributes["dew_point"] == 3.0 + assert state.attributes["apparent_temperature"] == 3.0 + assert state.context is context + + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + { + "entity_id": state.entity_id, + "type": "daily", + }, + blocking=True, + return_response=True, + ) + assert response == { + "forecast": [ + { + "datetime": now, + "condition": "sunny", + "precipitation": 20.0, + "temperature": 20.0, + "templow": 15.0, + } + ], + } + + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + { + "entity_id": state.entity_id, + "type": "hourly", + }, + blocking=True, + return_response=True, + ) + assert response == { + "forecast": [ + { + "datetime": now, + "condition": "sunny", + "precipitation": 20.0, + "temperature": 20.0, + "templow": 15.0, + } + ], + } + + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + { + "entity_id": state.entity_id, + "type": "twice_daily", + }, + blocking=True, + return_response=True, + ) + assert response == { + "forecast": [ + { + "datetime": now, + "condition": "sunny", + "precipitation": 20.0, + "temperature": 20.0, + "templow": 15.0, + "is_daytime": True, + } + ], + } + + +async def test_restore_weather_save_state( + hass: HomeAssistant, + hass_storage: dict[str, Any], +) -> None: + """Test Restore saved state for Weather trigger template.""" + assert await async_setup_component( + hass, + "template", + { + "template": { + "trigger": {"platform": "event", "event_type": "test_event"}, + "weather": { + "name": "test", + "condition_template": "{{ trigger.event.data.condition }}", + "temperature_template": "{{ trigger.event.data.temperature | float }}", + "temperature_unit": "°C", + "humidity_template": "{{ trigger.event.data.humidity | float }}", + }, + }, + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + hass.bus.async_fire( + "test_event", {"condition": "cloudy", "temperature": 15, "humidity": 25} + ) + await hass.async_block_till_done() + entity = hass.states.get("weather.test") + + # Trigger saving state + await async_mock_restore_state_shutdown_restart(hass) + + assert len(hass_storage[RESTORE_STATE_KEY]["data"]) == 1 + state = hass_storage[RESTORE_STATE_KEY]["data"][0]["state"] + assert state["entity_id"] == entity.entity_id + extra_data = hass_storage[RESTORE_STATE_KEY]["data"][0]["extra_data"] + assert extra_data == { + "last_apparent_temperature": None, + "last_cloud_coverage": None, + "last_dew_point": None, + "last_humidity": "25.0", + "last_ozone": None, + "last_pressure": None, + "last_temperature": "15.0", + "last_visibility": None, + "last_wind_bearing": None, + "last_wind_gust_speed": None, + "last_wind_speed": None, + } + + +SAVED_ATTRIBUTES_1 = { + "humidity": 20, + "temperature": 10, +} + +SAVED_EXTRA_DATA_MISSING_KEY = { + "last_cloud_coverage": None, + "last_dew_point": None, + "last_humidity": 20, + "last_ozone": None, + "last_pressure": None, + "last_temperature": 20, + "last_visibility": None, + "last_wind_bearing": None, + "last_wind_gust_speed": None, + "last_wind_speed": None, +} + + +@pytest.mark.parametrize( + ("saved_attributes", "saved_extra_data"), + [ + (SAVED_ATTRIBUTES_1, SAVED_EXTRA_DATA_MISSING_KEY), + (SAVED_ATTRIBUTES_1, None), + ], +) +async def test_trigger_entity_restore_state_fail( + hass: HomeAssistant, + saved_attributes: dict, + saved_extra_data: dict | None, +) -> None: + """Test restoring trigger template weather fails due to missing attribute.""" + + saved_state = State( + "weather.test", + None, + saved_attributes, + ) + mock_restore_cache_with_extra_data(hass, ((saved_state, saved_extra_data),)) + assert await async_setup_component( + hass, + "template", + { + "template": { + "trigger": {"platform": "event", "event_type": "test_event"}, + "weather": { + "name": "test", + "condition_template": "{{ trigger.event.data.condition }}", + "temperature_template": "{{ trigger.event.data.temperature | float }}", + "temperature_unit": "°C", + "humidity_template": "{{ trigger.event.data.humidity | float }}", + }, + }, + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + state = hass.states.get("weather.test") + assert state.state == STATE_UNKNOWN + assert state.attributes.get("temperature") is None diff --git a/tests/components/thread/test_config_flow.py b/tests/components/thread/test_config_flow.py index 7ff096795ca..51ebe3b5976 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.core import HomeAssistant 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/timer/test_init.py b/tests/components/timer/test_init.py index eabc5e04e0b..6b6929e88ec 100644 --- a/tests/components/timer/test_init.py +++ b/tests/components/timer/test_init.py @@ -319,7 +319,7 @@ async def test_start_service(hass: HomeAssistant) -> None: with pytest.raises( HomeAssistantError, - match="Not possible to change timer timer.test1 beyond configured duration", + match="Not possible to change timer timer.test1 beyond duration", ): await hass.services.async_call( DOMAIN, @@ -370,7 +370,7 @@ async def test_start_service(hass: HomeAssistant) -> None: state = hass.states.get("timer.test1") assert state assert state.state == STATUS_IDLE - assert state.attributes[ATTR_DURATION] == "0:00:15" + assert state.attributes[ATTR_DURATION] == "0:00:10" assert ATTR_REMAINING not in state.attributes with pytest.raises( @@ -387,7 +387,7 @@ async def test_start_service(hass: HomeAssistant) -> None: state = hass.states.get("timer.test1") assert state assert state.state == STATUS_IDLE - assert state.attributes[ATTR_DURATION] == "0:00:15" + assert state.attributes[ATTR_DURATION] == "0:00:10" assert ATTR_REMAINING not in state.attributes @@ -844,43 +844,6 @@ async def test_setup_no_config(hass: HomeAssistant, hass_admin_user: MockUser) - assert count_start == len(hass.states.async_entity_ids()) -async def test_restore_idle(hass: HomeAssistant) -> None: - """Test entity restore logic when timer is idle.""" - utc_now = utcnow() - stored_state = StoredState( - State( - "timer.test", - STATUS_IDLE, - {ATTR_DURATION: "0:00:30"}, - ), - None, - utc_now, - ) - - data = async_get(hass) - await data.store.async_save([stored_state.as_dict()]) - await data.async_load() - - entity = Timer.from_storage( - { - CONF_ID: "test", - CONF_NAME: "test", - CONF_DURATION: "0:01:00", - CONF_RESTORE: True, - } - ) - entity.hass = hass - entity.entity_id = "timer.test" - - await entity.async_added_to_hass() - await hass.async_block_till_done() - assert entity.state == STATUS_IDLE - assert entity.extra_state_attributes[ATTR_DURATION] == "0:00:30" - assert ATTR_REMAINING not in entity.extra_state_attributes - assert ATTR_FINISHES_AT not in entity.extra_state_attributes - assert entity.extra_state_attributes[ATTR_RESTORE] - - @pytest.mark.freeze_time("2023-06-05 17:47:50") async def test_restore_paused(hass: HomeAssistant) -> None: """Test entity restore logic when timer is paused.""" @@ -1007,7 +970,7 @@ async def test_restore_active_finished_outside_grace(hass: HomeAssistant) -> Non await hass.async_block_till_done() assert entity.state == STATUS_IDLE - assert entity.extra_state_attributes[ATTR_DURATION] == "0:00:30" + assert entity.extra_state_attributes[ATTR_DURATION] == "0:01:00" assert ATTR_REMAINING not in entity.extra_state_attributes assert ATTR_FINISHES_AT not in entity.extra_state_attributes assert entity.extra_state_attributes[ATTR_RESTORE] diff --git a/tests/components/todoist/conftest.py b/tests/components/todoist/conftest.py new file mode 100644 index 00000000000..6543e5b678f --- /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 921439fab45..45300e2e66c 100644 --- a/tests/components/todoist/test_calendar.py +++ b/tests/components/todoist/test_calendar.py @@ -7,7 +7,7 @@ import urllib 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 import entity_registry as er 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 00000000000..4175902da31 --- /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 00000000000..cc64464df1d --- /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 aa88766c395..df36570497b 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/tradfri/test_config_flow.py b/tests/components/tradfri/test_config_flow.py index 9eff7335820..3f5c71645c8 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 00000000000..6f7eb540289 --- /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 38c49d54208..aa6122b7efe 100644 --- a/tests/components/trafikverket_camera/test_config_flow.py +++ b/tests/components/trafikverket_camera/test_config_flow.py @@ -10,6 +10,7 @@ from pytrafikverket.exceptions import ( 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 homeassistant.data_entry_flow import FlowResultType 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 021433b33e7..b9add7ae483 100644 --- a/tests/components/trafikverket_camera/test_recorder.py +++ b/tests/components/trafikverket_camera/test_recorder.py @@ -16,6 +16,7 @@ from tests.test_util.aiohttp import AiohttpClientMocker 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 00000000000..581fed1d289 --- /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/trend/test_binary_sensor.py b/tests/components/trend/test_binary_sensor.py index c477b9a11fe..cccf1add61b 100644 --- a/tests/components/trend/test_binary_sensor.py +++ b/tests/components/trend/test_binary_sensor.py @@ -2,16 +2,19 @@ from datetime import timedelta from unittest.mock import patch +import pytest + from homeassistant import config as hass_config, setup from homeassistant.components.trend.const import DOMAIN from homeassistant.const import SERVICE_RELOAD, STATE_UNKNOWN -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, State import homeassistant.util.dt as dt_util from tests.common import ( assert_setup_component, get_fixture_path, get_test_home_assistant, + mock_restore_cache, ) @@ -413,3 +416,28 @@ async def test_reload(hass: HomeAssistant) -> None: assert hass.states.get("binary_sensor.test_trend_sensor") is None assert hass.states.get("binary_sensor.second_test_trend_sensor") + + +@pytest.mark.parametrize( + ("saved_state", "restored_state"), + [("on", "on"), ("off", "off"), ("unknown", "unknown")], +) +async def test_restore_state( + hass: HomeAssistant, saved_state: str, restored_state: str +) -> None: + """Test we restore the trend state.""" + mock_restore_cache(hass, (State("binary_sensor.test_trend_sensor", saved_state),)) + + assert await setup.async_setup_component( + hass, + "binary_sensor", + { + "binary_sensor": { + "platform": "trend", + "sensors": {"test_trend_sensor": {"entity_id": "sensor.test_state"}}, + } + }, + ) + await hass.async_block_till_done() + + assert hass.states.get("binary_sensor.test_trend_sensor").state == restored_state diff --git a/tests/components/twinkly/__init__.py b/tests/components/twinkly/__init__.py index 31d1eff2a61..bd51ac5d7cd 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 @@ from homeassistant.components.twinkly.const import DEV_NAME 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,10 +28,10 @@ class ClientMock: 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, } diff --git a/tests/components/twinkly/conftest.py b/tests/components/twinkly/conftest.py new file mode 100644 index 00000000000..5a689c31baa --- /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 00000000000..cda2ad3d60e --- /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', + '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, + }), + 'sw_version': '2.8.10', + }) +# --- diff --git a/tests/components/twinkly/test_config_flow.py b/tests/components/twinkly/test_config_flow.py index 1219130c197..2d335c69923 100644 --- a/tests/components/twinkly/test_config_flow.py +++ b/tests/components/twinkly/test_config_flow.py @@ -12,7 +12,7 @@ from homeassistant.components.twinkly.const import ( 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 00000000000..ab07cabef4a --- /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 f66c82dc2ed..bcb40f22d08 100644 --- a/tests/components/twinkly/test_light.py +++ b/tests/components/twinkly/test_light.py @@ -16,7 +16,7 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er 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/twitch/__init__.py b/tests/components/twitch/__init__.py index bf35484f53e..26746c7abb4 100644 --- a/tests/components/twitch/__init__.py +++ b/tests/components/twitch/__init__.py @@ -1,10 +1,10 @@ """Tests for the Twitch component.""" import asyncio -from collections.abc import AsyncGenerator +from collections.abc import AsyncGenerator, AsyncIterator from dataclasses import dataclass -from typing import Any +from datetime import datetime -from twitchAPI.object import TwitchUser +from twitchAPI.object.api import FollowedChannelsResult, TwitchUser from twitchAPI.twitch import ( InvalidTokenException, MissingScopeException, @@ -12,24 +12,34 @@ from twitchAPI.twitch import ( TwitchAuthorizationException, TwitchResourceNotFound, ) -from twitchAPI.types import AuthScope, AuthType +from twitchAPI.type import AuthScope, AuthType -USER_OBJECT: TwitchUser = TwitchUser( - id=123, - display_name="channel123", - offline_image_url="logo.png", - profile_image_url="logo.png", - view_count=42, -) +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry -class TwitchUserFollowResultMock: - """Mock for twitch user follow result.""" +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) - def __init__(self, follows: list[dict[str, Any]]) -> None: - """Initialize mock.""" - self.total = len(follows) - self.data = follows + await hass.config_entries.async_setup(config_entry.entry_id) + + +def _get_twitch_user(user_id: str = "123") -> TwitchUser: + return TwitchUser( + id=user_id, + display_name="channel123", + offline_image_url="logo.png", + profile_image_url="logo.png", + view_count=42, + ) + + +async def async_iterator(iterable) -> AsyncIterator: + """Return async iterator.""" + for i in iterable: + yield i @dataclass @@ -41,12 +51,20 @@ class UserSubscriptionMock: @dataclass -class UserFollowMock: - """User follow mock.""" +class FollowedChannelMock: + """Followed channel mock.""" + broadcaster_login: str followed_at: str +@dataclass +class ChannelFollowerMock: + """Channel follower mock.""" + + user_id: str + + @dataclass class StreamMock: """Stream mock.""" @@ -56,6 +74,32 @@ class StreamMock: thumbnail_url: str +class TwitchUserFollowResultMock: + """Mock for twitch user follow result.""" + + def __init__(self, follows: list[FollowedChannelMock]) -> None: + """Initialize mock.""" + self.total = len(follows) + self.data = follows + + def __aiter__(self): + """Return async iterator.""" + return async_iterator(self.data) + + +class ChannelFollowersResultMock: + """Mock for twitch channel follow result.""" + + def __init__(self, follows: list[ChannelFollowerMock]) -> None: + """Initialize mock.""" + self.total = len(follows) + self.data = follows + + def __aiter__(self): + """Return async iterator.""" + return async_iterator(self.data) + + STREAMS = StreamMock( game_name="Good game", title="Title", thumbnail_url="stream-medium.png" ) @@ -64,25 +108,18 @@ STREAMS = StreamMock( class TwitchMock: """Mock for the twitch object.""" + is_streaming = True + is_gifted = False + is_subscribed = False + is_following = True + different_user_id = False + def __await__(self): """Add async capabilities to the mock.""" t = asyncio.create_task(self._noop()) yield from t return self - def __init__( - self, - is_streaming: bool = True, - is_gifted: bool = False, - is_subscribed: bool = False, - is_following: bool = True, - ) -> None: - """Initialize mock.""" - self._is_streaming = is_streaming - self._is_gifted = is_gifted - self._is_subscribed = is_subscribed - self._is_following = is_following - async def _noop(self): """Fake function to create task.""" pass @@ -91,7 +128,8 @@ class TwitchMock: self, user_ids: list[str] | None = None, logins: list[str] | None = None ) -> AsyncGenerator[TwitchUser, None]: """Get list of mock users.""" - for user in [USER_OBJECT]: + users = [_get_twitch_user("234" if self.different_user_id else "123")] + for user in users: yield user def has_required_auth( @@ -100,38 +138,56 @@ class TwitchMock: """Return if auth required.""" return True - async def get_users_follows( - self, to_id: str | None = None, from_id: str | None = None - ) -> TwitchUserFollowResultMock: - """Return the followers of the user.""" - if self._is_following: - return TwitchUserFollowResultMock( - follows=[UserFollowMock("2020-01-20T21:22:42") for _ in range(0, 24)] - ) - return TwitchUserFollowResultMock(follows=[]) - async def check_user_subscription( self, broadcaster_id: str, user_id: str ) -> UserSubscriptionMock: """Check if the user is subscribed.""" - if self._is_subscribed: + if self.is_subscribed: return UserSubscriptionMock( - broadcaster_id=broadcaster_id, is_gift=self._is_gifted + broadcaster_id=broadcaster_id, is_gift=self.is_gifted ) raise TwitchResourceNotFound async def set_user_authentication( - self, token: str, scope: list[AuthScope], validate: bool = True + self, + token: str, + scope: list[AuthScope], + validate: bool = True, ) -> None: """Set user authentication.""" pass + async def get_followed_channels( + self, user_id: str, broadcaster_id: str | None = None + ) -> FollowedChannelsResult: + """Get followed channels.""" + if self.is_following: + return TwitchUserFollowResultMock( + [ + FollowedChannelMock( + followed_at=datetime(year=2023, month=8, day=1), + broadcaster_login="internetofthings", + ), + FollowedChannelMock( + followed_at=datetime(year=2023, month=8, day=1), + broadcaster_login="homeassistant", + ), + ] + ) + return TwitchUserFollowResultMock([]) + + async def get_channel_followers( + self, broadcaster_id: str + ) -> ChannelFollowersResultMock: + """Get channel followers.""" + return ChannelFollowersResultMock([ChannelFollowerMock(user_id="abc")]) + async def get_streams( self, user_id: list[str], first: int ) -> AsyncGenerator[StreamMock, None]: """Get streams for the user.""" streams = [] - if self._is_streaming: + if self.is_streaming: streams = [STREAMS] for stream in streams: yield stream diff --git a/tests/components/twitch/conftest.py b/tests/components/twitch/conftest.py new file mode 100644 index 00000000000..b3894203786 --- /dev/null +++ b/tests/components/twitch/conftest.py @@ -0,0 +1,110 @@ +"""Configure tests for the Twitch integration.""" +from collections.abc import Awaitable, Callable, Generator +import time +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.twitch.const import DOMAIN, OAUTH2_TOKEN, OAUTH_SCOPES +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry +from tests.components.twitch import TwitchMock +from tests.test_util.aiohttp import AiohttpClientMocker + +ComponentSetup = Callable[[TwitchMock | None], Awaitable[None]] + +CLIENT_ID = "1234" +CLIENT_SECRET = "5678" +TITLE = "Test" + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.twitch.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture(name="scopes") +def mock_scopes() -> list[str]: + """Fixture to set the scopes present in the OAuth token.""" + return [scope.value for scope in OAUTH_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 Twitch entry in Home Assistant.""" + return MockConfigEntry( + domain=DOMAIN, + title=TITLE, + unique_id="123", + data={ + "auth_implementation": DOMAIN, + "token": { + "access_token": "mock-access-token", + "refresh_token": "mock-refresh-token", + "expires_at": expires_at, + "scope": " ".join(scopes), + }, + }, + options={"channels": ["internetofthings"]}, + ) + + +@pytest.fixture(autouse=True) +def mock_connection(aioclient_mock: AiohttpClientMocker) -> None: + """Mock Twitch connection.""" + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + +@pytest.fixture(name="twitch_mock") +def twitch_mock() -> TwitchMock: + """Return as fixture to inject other mocks.""" + return TwitchMock() + + +@pytest.fixture(name="twitch") +def mock_twitch(twitch_mock: TwitchMock): + """Mock Twitch.""" + with patch( + "homeassistant.components.twitch.Twitch", + return_value=twitch_mock, + ), patch( + "homeassistant.components.twitch.config_flow.Twitch", + return_value=twitch_mock, + ): + yield twitch_mock diff --git a/tests/components/twitch/test_config_flow.py b/tests/components/twitch/test_config_flow.py new file mode 100644 index 00000000000..36312fea83e --- /dev/null +++ b/tests/components/twitch/test_config_flow.py @@ -0,0 +1,295 @@ +"""Test config flow for Twitch.""" +from unittest.mock import patch + +import pytest + +from homeassistant.components.twitch.const import ( + CONF_CHANNELS, + DOMAIN, + OAUTH2_AUTHORIZE, +) +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_REAUTH, SOURCE_USER +from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult, FlowResultType +from homeassistant.helpers import config_entry_oauth2_flow, issue_registry as ir + +from . import setup_integration + +from tests.common import MockConfigEntry +from tests.components.twitch import TwitchInvalidTokenMock, TwitchMock +from tests.components.twitch.conftest import CLIENT_ID, TITLE +from tests.typing import ClientSessionGenerator + + +async def _do_get_token( + hass: HomeAssistant, + result: FlowResult, + hass_client_no_auth: ClientSessionGenerator, + scopes: list[str], +) -> None: + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}&scope={'+'.join(scopes)}" + ) + + 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" + + +async def test_full_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + current_request_with_host: None, + mock_setup_entry, + twitch: TwitchMock, + scopes: list[str], +) -> None: + """Check full flow.""" + result = await hass.config_entries.flow.async_init( + "twitch", context={"source": SOURCE_USER} + ) + await _do_get_token(hass, result, hass_client_no_auth, scopes) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "channel123" + assert "result" in result + assert "token" in result["result"].data + assert result["result"].data["token"]["access_token"] == "mock-access-token" + assert result["result"].data["token"]["refresh_token"] == "mock-refresh-token" + assert result["result"].unique_id == "123" + assert result["options"] == {CONF_CHANNELS: ["internetofthings", "homeassistant"]} + + +async def test_already_configured( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + current_request_with_host: None, + config_entry: MockConfigEntry, + mock_setup_entry, + twitch: TwitchMock, + scopes: list[str], +) -> None: + """Check flow aborts when account already configured.""" + await setup_integration(hass, config_entry) + result = await hass.config_entries.flow.async_init( + "twitch", context={"source": SOURCE_USER} + ) + await _do_get_token(hass, result, hass_client_no_auth, scopes) + + with patch( + "homeassistant.components.twitch.config_flow.Twitch", return_value=TwitchMock() + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_reauth( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + current_request_with_host: None, + config_entry: MockConfigEntry, + mock_setup_entry, + twitch: TwitchMock, + scopes: list[str], +) -> None: + """Check reauth flow.""" + 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, + }, + data=config_entry.data, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + await _do_get_token(hass, result, hass_client_no_auth, scopes) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + +async def test_reauth_from_import( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + current_request_with_host: None, + mock_setup_entry, + twitch: TwitchMock, + expires_at, + scopes: list[str], +) -> None: + """Check reauth flow.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title=TITLE, + unique_id="123", + data={ + "auth_implementation": DOMAIN, + "token": { + "access_token": "mock-access-token", + "refresh_token": "mock-refresh-token", + "expires_at": expires_at, + "scope": " ".join(scopes), + }, + "imported": True, + }, + options={"channels": ["internetofthings"]}, + ) + await test_reauth( + hass, + hass_client_no_auth, + current_request_with_host, + config_entry, + mock_setup_entry, + twitch, + scopes, + ) + entries = hass.config_entries.async_entries(DOMAIN) + entry = entries[0] + assert "imported" not in entry.data + assert entry.options == {CONF_CHANNELS: ["internetofthings", "homeassistant"]} + + +async def test_reauth_wrong_account( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + current_request_with_host: None, + config_entry: MockConfigEntry, + mock_setup_entry, + twitch: TwitchMock, + scopes: list[str], +) -> None: + """Check reauth flow.""" + await setup_integration(hass, config_entry) + twitch.different_user_id = True + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "entry_id": config_entry.entry_id, + }, + data=config_entry.data, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + await _do_get_token(hass, result, hass_client_no_auth, scopes) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "wrong_account" + + +async def test_import( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + current_request_with_host: None, + mock_setup_entry, + twitch: TwitchMock, +) -> None: + """Test import flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_IMPORT, + }, + data={ + "platform": "twitch", + CONF_CLIENT_ID: "1234", + CONF_CLIENT_SECRET: "abcd", + CONF_TOKEN: "efgh", + "channels": ["channel123"], + }, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "channel123" + assert "result" in result + assert "token" in result["result"].data + assert result["result"].data["token"]["access_token"] == "efgh" + assert result["result"].data["token"]["refresh_token"] == "" + assert result["result"].unique_id == "123" + assert result["options"] == {CONF_CHANNELS: ["channel123"]} + + +@pytest.mark.parametrize("twitch_mock", [TwitchInvalidTokenMock()]) +async def test_import_invalid_token( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + current_request_with_host: None, + mock_setup_entry, + twitch: TwitchMock, +) -> None: + """Test import flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_IMPORT, + }, + data={ + "platform": "twitch", + CONF_CLIENT_ID: "1234", + CONF_CLIENT_SECRET: "abcd", + CONF_TOKEN: "efgh", + "channels": ["channel123"], + }, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "invalid_token" + issue_registry = ir.async_get(hass) + assert len(issue_registry.issues) == 1 + + +async def test_import_already_imported( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + current_request_with_host: None, + config_entry: MockConfigEntry, + mock_setup_entry, + twitch: TwitchMock, +) -> None: + """Test import flow where the config is already imported.""" + await setup_integration(hass, config_entry) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_IMPORT, + }, + data={ + "platform": "twitch", + CONF_CLIENT_ID: "1234", + CONF_CLIENT_SECRET: "abcd", + CONF_TOKEN: "efgh", + "channels": ["channel123"], + }, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + issue_registry = ir.async_get(hass) + assert len(issue_registry.issues) == 1 diff --git a/tests/components/twitch/test_init.py b/tests/components/twitch/test_init.py new file mode 100644 index 00000000000..da03857a95d --- /dev/null +++ b/tests/components/twitch/test_init.py @@ -0,0 +1,116 @@ +"""Tests for YouTube.""" +import http +import time +from unittest.mock import patch + +from aiohttp.client_exceptions import ClientError +import pytest + +from homeassistant.components.twitch.const import DOMAIN, OAUTH2_TOKEN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import TwitchMock, setup_integration + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker + + +async def test_setup_success( + hass: HomeAssistant, config_entry: MockConfigEntry, twitch: TwitchMock +) -> None: + """Test successful setup and unload.""" + await setup_integration(hass, config_entry) + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(entries[0].entry_id) + await hass.async_block_till_done() + + assert not hass.services.async_services().get(DOMAIN) + + +@pytest.mark.parametrize("expires_at", [time.time() - 3600], ids=["expired"]) +async def test_expired_token_refresh_success( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + config_entry: MockConfigEntry, + twitch: TwitchMock, +) -> None: + """Test expired token is refreshed.""" + + aioclient_mock.clear_requests() + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "access_token": "updated-access-token", + "refresh_token": "updated-refresh-token", + "expires_at": time.time() + 3600, + "expires_in": 3600, + }, + ) + + await setup_integration(hass, config_entry) + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.LOADED + assert entries[0].data["token"]["access_token"] == "updated-access-token" + assert entries[0].data["token"]["expires_in"] == 3600 + + +@pytest.mark.parametrize( + ("expires_at", "status", "expected_state"), + [ + ( + time.time() - 3600, + http.HTTPStatus.UNAUTHORIZED, + ConfigEntryState.SETUP_ERROR, + ), + ( + time.time() - 3600, + http.HTTPStatus.INTERNAL_SERVER_ERROR, + ConfigEntryState.SETUP_RETRY, + ), + ], + ids=["failure_requires_reauth", "transient_failure"], +) +async def test_expired_token_refresh_failure( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + status: http.HTTPStatus, + expected_state: ConfigEntryState, + config_entry: MockConfigEntry, + twitch: TwitchMock, +) -> None: + """Test failure while refreshing token with a transient error.""" + + aioclient_mock.clear_requests() + aioclient_mock.post( + OAUTH2_TOKEN, + status=status, + ) + + await setup_integration(hass, config_entry) + + # Verify a transient failure has occurred + entries = hass.config_entries.async_entries(DOMAIN) + assert entries[0].state is expected_state + + +async def test_expired_token_refresh_client_error( + hass: HomeAssistant, config_entry: MockConfigEntry, twitch: TwitchMock +) -> None: + """Test failure while refreshing token with a client error.""" + + with patch( + "homeassistant.components.twitch.OAuth2Session.async_ensure_token_valid", + side_effect=ClientError, + ): + await setup_integration(hass, config_entry) + + # Verify a transient failure has occurred + entries = hass.config_entries.async_entries(DOMAIN) + assert entries[0].state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/twitch/test_sensor.py b/tests/components/twitch/test_sensor.py new file mode 100644 index 00000000000..047c55d3b72 --- /dev/null +++ b/tests/components/twitch/test_sensor.py @@ -0,0 +1,177 @@ +"""The tests for an update of the Twitch component.""" +from datetime import datetime + +import pytest + +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.twitch.const import CONF_CHANNELS, DOMAIN +from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_TOKEN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.setup import async_setup_component + +from ...common import MockConfigEntry +from . import ( + TwitchAPIExceptionMock, + TwitchInvalidTokenMock, + TwitchInvalidUserMock, + TwitchMissingScopeMock, + TwitchMock, + TwitchUnauthorizedMock, + setup_integration, +) + +ENTITY_ID = "sensor.channel123" +CONFIG = { + "auth_implementation": "cred", + CONF_CLIENT_ID: "1234", + CONF_CLIENT_SECRET: "abcd", +} + +LEGACY_CONFIG_WITHOUT_TOKEN = { + SENSOR_DOMAIN: { + "platform": "twitch", + CONF_CLIENT_ID: "1234", + CONF_CLIENT_SECRET: "abcd", + "channels": ["channel123"], + } +} + +LEGACY_CONFIG = { + SENSOR_DOMAIN: { + "platform": "twitch", + CONF_CLIENT_ID: "1234", + CONF_CLIENT_SECRET: "abcd", + CONF_TOKEN: "efgh", + "channels": ["channel123"], + } +} + +OPTIONS = {CONF_CHANNELS: ["channel123"]} + + +async def test_legacy_migration( + hass: HomeAssistant, twitch: TwitchMock, mock_setup_entry +) -> None: + """Test importing legacy yaml.""" + 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 + issue_registry = ir.async_get(hass) + assert len(issue_registry.issues) == 1 + + +async def test_legacy_migration_without_token( + hass: HomeAssistant, twitch: TwitchMock +) -> None: + """Test importing legacy yaml.""" + assert await async_setup_component( + hass, Platform.SENSOR, LEGACY_CONFIG_WITHOUT_TOKEN + ) + await hass.async_block_till_done() + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 0 + issue_registry = ir.async_get(hass) + assert len(issue_registry.issues) == 1 + + +async def test_offline( + hass: HomeAssistant, twitch: TwitchMock, config_entry: MockConfigEntry +) -> None: + """Test offline state.""" + twitch.is_streaming = False + await setup_integration(hass, config_entry) + + sensor_state = hass.states.get(ENTITY_ID) + assert sensor_state.state == "offline" + assert sensor_state.attributes["entity_picture"] == "logo.png" + + +async def test_streaming( + hass: HomeAssistant, twitch: TwitchMock, config_entry: MockConfigEntry +) -> None: + """Test streaming state.""" + await setup_integration(hass, config_entry) + + sensor_state = hass.states.get(ENTITY_ID) + assert sensor_state.state == "streaming" + assert sensor_state.attributes["entity_picture"] == "stream-medium.png" + assert sensor_state.attributes["game"] == "Good game" + assert sensor_state.attributes["title"] == "Title" + + +async def test_oauth_without_sub_and_follow( + hass: HomeAssistant, twitch: TwitchMock, config_entry: MockConfigEntry +) -> None: + """Test state with oauth.""" + twitch.is_following = False + await setup_integration(hass, config_entry) + + sensor_state = hass.states.get(ENTITY_ID) + assert sensor_state.attributes["subscribed"] is False + assert sensor_state.attributes["following"] is False + + +async def test_oauth_with_sub( + hass: HomeAssistant, twitch: TwitchMock, config_entry: MockConfigEntry +) -> None: + """Test state with oauth and sub.""" + twitch.is_subscribed = True + twitch.is_following = False + await setup_integration(hass, config_entry) + + sensor_state = hass.states.get(ENTITY_ID) + assert sensor_state.attributes["subscribed"] is True + assert sensor_state.attributes["subscription_is_gifted"] is False + assert sensor_state.attributes["following"] is False + + +async def test_oauth_with_follow( + hass: HomeAssistant, twitch: TwitchMock, config_entry: MockConfigEntry +) -> None: + """Test state with oauth and follow.""" + await setup_integration(hass, config_entry) + + sensor_state = hass.states.get(ENTITY_ID) + assert sensor_state.attributes["following"] is True + assert sensor_state.attributes["following_since"] == datetime( + year=2023, month=8, day=1 + ) + + +@pytest.mark.parametrize( + "twitch_mock", + [TwitchUnauthorizedMock(), TwitchMissingScopeMock(), TwitchInvalidTokenMock()], +) +async def test_auth_invalid( + hass: HomeAssistant, twitch: TwitchMock, config_entry: MockConfigEntry +) -> None: + """Test auth failures.""" + await setup_integration(hass, config_entry) + + sensor_state = hass.states.get(ENTITY_ID) + assert sensor_state is None + + +@pytest.mark.parametrize("twitch_mock", [TwitchInvalidUserMock()]) +async def test_auth_with_invalid_user( + hass: HomeAssistant, twitch: TwitchMock, config_entry: MockConfigEntry +) -> None: + """Test auth with invalid user.""" + await setup_integration(hass, config_entry) + + sensor_state = hass.states.get(ENTITY_ID) + assert "subscribed" not in sensor_state.attributes + + +@pytest.mark.parametrize("twitch_mock", [TwitchAPIExceptionMock()]) +async def test_auth_with_api_exception( + hass: HomeAssistant, twitch: TwitchMock, config_entry: MockConfigEntry +) -> None: + """Test auth with invalid user.""" + await setup_integration(hass, config_entry) + + sensor_state = hass.states.get(ENTITY_ID) + assert sensor_state.attributes["subscribed"] is False + assert "subscription_is_gifted" not in sensor_state.attributes diff --git a/tests/components/twitch/test_twitch.py b/tests/components/twitch/test_twitch.py deleted file mode 100644 index 4a33831dd32..00000000000 --- a/tests/components/twitch/test_twitch.py +++ /dev/null @@ -1,205 +0,0 @@ -"""The tests for an update of the Twitch component.""" -from unittest.mock import patch - -from homeassistant.components import sensor -from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET -from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component - -from . import ( - TwitchAPIExceptionMock, - TwitchInvalidTokenMock, - TwitchInvalidUserMock, - TwitchMissingScopeMock, - TwitchMock, - TwitchUnauthorizedMock, -) - -ENTITY_ID = "sensor.channel123" -CONFIG = { - sensor.DOMAIN: { - "platform": "twitch", - CONF_CLIENT_ID: "1234", - CONF_CLIENT_SECRET: " abcd", - "channels": ["channel123"], - } -} -CONFIG_WITH_OAUTH = { - sensor.DOMAIN: { - "platform": "twitch", - CONF_CLIENT_ID: "1234", - CONF_CLIENT_SECRET: "abcd", - "channels": ["channel123"], - "token": "9876", - } -} - - -async def test_init(hass: HomeAssistant) -> None: - """Test initial config.""" - - with patch( - "homeassistant.components.twitch.sensor.Twitch", - return_value=TwitchMock(is_streaming=False), - ): - assert await async_setup_component(hass, sensor.DOMAIN, CONFIG) is True - await hass.async_block_till_done() - - sensor_state = hass.states.get(ENTITY_ID) - assert sensor_state.state == "offline" - assert sensor_state.name == "channel123" - assert sensor_state.attributes["icon"] == "mdi:twitch" - assert sensor_state.attributes["friendly_name"] == "channel123" - assert sensor_state.attributes["views"] == 42 - assert sensor_state.attributes["followers"] == 24 - - -async def test_offline(hass: HomeAssistant) -> None: - """Test offline state.""" - - with patch( - "homeassistant.components.twitch.sensor.Twitch", - return_value=TwitchMock(is_streaming=False), - ): - assert await async_setup_component(hass, sensor.DOMAIN, CONFIG) is True - await hass.async_block_till_done() - - sensor_state = hass.states.get(ENTITY_ID) - assert sensor_state.state == "offline" - assert sensor_state.attributes["entity_picture"] == "logo.png" - - -async def test_streaming(hass: HomeAssistant) -> None: - """Test streaming state.""" - - with patch( - "homeassistant.components.twitch.sensor.Twitch", - return_value=TwitchMock(), - ): - assert await async_setup_component(hass, sensor.DOMAIN, CONFIG) is True - await hass.async_block_till_done() - - sensor_state = hass.states.get(ENTITY_ID) - assert sensor_state.state == "streaming" - assert sensor_state.attributes["entity_picture"] == "stream-medium.png" - assert sensor_state.attributes["game"] == "Good game" - assert sensor_state.attributes["title"] == "Title" - - -async def test_oauth_without_sub_and_follow(hass: HomeAssistant) -> None: - """Test state with oauth.""" - - with patch( - "homeassistant.components.twitch.sensor.Twitch", - return_value=TwitchMock(is_following=False), - ): - assert await async_setup_component(hass, sensor.DOMAIN, CONFIG_WITH_OAUTH) - await hass.async_block_till_done() - - sensor_state = hass.states.get(ENTITY_ID) - assert sensor_state.attributes["subscribed"] is False - assert sensor_state.attributes["following"] is False - - -async def test_oauth_with_sub(hass: HomeAssistant) -> None: - """Test state with oauth and sub.""" - - with patch( - "homeassistant.components.twitch.sensor.Twitch", - return_value=TwitchMock( - is_subscribed=True, is_gifted=False, is_following=False - ), - ): - assert await async_setup_component(hass, sensor.DOMAIN, CONFIG_WITH_OAUTH) - await hass.async_block_till_done() - - sensor_state = hass.states.get(ENTITY_ID) - assert sensor_state.attributes["subscribed"] is True - assert sensor_state.attributes["subscription_is_gifted"] is False - assert sensor_state.attributes["following"] is False - - -async def test_oauth_with_follow(hass: HomeAssistant) -> None: - """Test state with oauth and follow.""" - - with patch( - "homeassistant.components.twitch.sensor.Twitch", - return_value=TwitchMock(), - ): - assert await async_setup_component(hass, sensor.DOMAIN, CONFIG_WITH_OAUTH) - await hass.async_block_till_done() - - sensor_state = hass.states.get(ENTITY_ID) - assert sensor_state.attributes["following"] is True - assert sensor_state.attributes["following_since"] == "2020-01-20T21:22:42" - - -async def test_auth_with_invalid_credentials(hass: HomeAssistant) -> None: - """Test auth with invalid credentials.""" - - with patch( - "homeassistant.components.twitch.sensor.Twitch", - return_value=TwitchUnauthorizedMock(), - ): - assert await async_setup_component(hass, sensor.DOMAIN, CONFIG_WITH_OAUTH) - await hass.async_block_till_done() - - sensor_state = hass.states.get(ENTITY_ID) - assert sensor_state is None - - -async def test_auth_with_missing_scope(hass: HomeAssistant) -> None: - """Test auth with invalid credentials.""" - - with patch( - "homeassistant.components.twitch.sensor.Twitch", - return_value=TwitchMissingScopeMock(), - ): - assert await async_setup_component(hass, sensor.DOMAIN, CONFIG_WITH_OAUTH) - await hass.async_block_till_done() - - sensor_state = hass.states.get(ENTITY_ID) - assert sensor_state is None - - -async def test_auth_with_invalid_token(hass: HomeAssistant) -> None: - """Test auth with invalid credentials.""" - - with patch( - "homeassistant.components.twitch.sensor.Twitch", - return_value=TwitchInvalidTokenMock(), - ): - assert await async_setup_component(hass, sensor.DOMAIN, CONFIG_WITH_OAUTH) - await hass.async_block_till_done() - - sensor_state = hass.states.get(ENTITY_ID) - assert sensor_state is None - - -async def test_auth_with_invalid_user(hass: HomeAssistant) -> None: - """Test auth with invalid user.""" - - with patch( - "homeassistant.components.twitch.sensor.Twitch", - return_value=TwitchInvalidUserMock(), - ): - assert await async_setup_component(hass, sensor.DOMAIN, CONFIG_WITH_OAUTH) - await hass.async_block_till_done() - - sensor_state = hass.states.get(ENTITY_ID) - assert "subscribed" not in sensor_state.attributes - - -async def test_auth_with_api_exception(hass: HomeAssistant) -> None: - """Test auth with invalid user.""" - - with patch( - "homeassistant.components.twitch.sensor.Twitch", - return_value=TwitchAPIExceptionMock(), - ): - assert await async_setup_component(hass, sensor.DOMAIN, CONFIG_WITH_OAUTH) - await hass.async_block_till_done() - - sensor_state = hass.states.get(ENTITY_ID) - assert sensor_state.attributes["subscribed"] is False - assert "subscription_is_gifted" not in sensor_state.attributes diff --git a/tests/components/unifi/conftest.py b/tests/components/unifi/conftest.py index ca0c855d1ab..d48ff613902 100644 --- a/tests/components/unifi/conftest.py +++ b/tests/components/unifi/conftest.py @@ -1,47 +1,100 @@ """Fixtures for UniFi Network methods.""" from __future__ import annotations +import asyncio +from datetime import timedelta from unittest.mock import patch from aiounifi.models.message import MessageKey -from aiounifi.websocket import WebsocketSignal, WebsocketState import pytest +from homeassistant.components.unifi.const import DOMAIN as UNIFI_DOMAIN +from homeassistant.components.unifi.controller import RETRY_TIMER +from homeassistant.const import CONTENT_TYPE_JSON +from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr +import homeassistant.util.dt as dt_util -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed +from tests.components.unifi.test_controller import DEFAULT_CONFIG_ENTRY_ID +from tests.test_util.aiohttp import AiohttpClientMocker + + +class WebsocketStateManager(asyncio.Event): + """Keep an async event that simules websocket context manager. + + Prepares disconnect and reconnect flows. + """ + + def __init__(self, hass: HomeAssistant, aioclient_mock: AiohttpClientMocker): + """Store hass object and initialize asyncio.Event.""" + self.hass = hass + self.aioclient_mock = aioclient_mock + super().__init__() + + async def disconnect(self): + """Mark future as done to make 'await self.api.start_websocket' return.""" + self.set() + await self.hass.async_block_till_done() + + async def reconnect(self, fail=False): + """Set up new future to make 'await self.api.start_websocket' block. + + Mock api calls done by 'await self.api.login'. + Fail will make 'await self.api.start_websocket' return immediately. + """ + controller = self.hass.data[UNIFI_DOMAIN][DEFAULT_CONFIG_ENTRY_ID] + self.aioclient_mock.get( + f"https://{controller.host}:1234", status=302 + ) # Check UniFi OS + self.aioclient_mock.post( + f"https://{controller.host}:1234/api/login", + json={"data": "login successful", "meta": {"rc": "ok"}}, + headers={"content-type": CONTENT_TYPE_JSON}, + ) + + if not fail: + self.clear() + new_time = dt_util.utcnow() + timedelta(seconds=RETRY_TIMER) + async_fire_time_changed(self.hass, new_time) + await self.hass.async_block_till_done() @pytest.fixture(autouse=True) -def mock_unifi_websocket(): - """No real websocket allowed.""" - with patch("aiounifi.controller.WSClient") as mock: +def websocket_mock(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker): + """Mock 'await self.api.start_websocket' in 'UniFiController.start_websocket'.""" + websocket_state_manager = WebsocketStateManager(hass, aioclient_mock) + with patch("aiounifi.Controller.start_websocket") as ws_mock: + ws_mock.side_effect = websocket_state_manager.wait + yield websocket_state_manager - def make_websocket_call( - *, - message: MessageKey | None = None, - data: list[dict] | dict | None = None, - state: WebsocketState | None = None, - ): - """Generate a websocket call.""" - if data and not message: - mock.return_value.data = data - mock.call_args[1]["callback"](WebsocketSignal.DATA) - elif data and message: - if not isinstance(data, list): - data = [data] - mock.return_value.data = { + +@pytest.fixture(autouse=True) +def mock_unifi_websocket(hass): + """No real websocket allowed.""" + + def make_websocket_call( + *, + message: MessageKey | None = None, + data: list[dict] | dict | None = None, + ): + """Generate a websocket call.""" + controller = hass.data[UNIFI_DOMAIN][DEFAULT_CONFIG_ENTRY_ID] + if data and not message: + controller.api.messages.handler(data) + elif data and message: + if not isinstance(data, list): + data = [data] + controller.api.messages.handler( + { "meta": {"message": message.value}, "data": data, } - mock.call_args[1]["callback"](WebsocketSignal.DATA) - elif state: - mock.return_value.state = state - mock.call_args[1]["callback"](WebsocketSignal.CONNECTION_STATE) - else: - raise NotImplementedError + ) + else: + raise NotImplementedError - yield make_websocket_call + return make_websocket_call @pytest.fixture(autouse=True) diff --git a/tests/components/unifi/test_button.py b/tests/components/unifi/test_button.py index 0c6ac38739e..30a1b3e08ff 100644 --- a/tests/components/unifi/test_button.py +++ b/tests/components/unifi/test_button.py @@ -1,7 +1,5 @@ """UniFi Network button platform tests.""" -from aiounifi.websocket import WebsocketState - from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, ButtonDeviceClass from homeassistant.components.unifi.const import DOMAIN as UNIFI_DOMAIN from homeassistant.const import ATTR_DEVICE_CLASS, STATE_UNAVAILABLE, EntityCategory @@ -14,7 +12,7 @@ from tests.test_util.aiohttp import AiohttpClientMocker async def test_restart_device_button( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_unifi_websocket + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, websocket_mock ) -> None: """Test restarting device button.""" config_entry = await setup_unifi_integration( @@ -71,11 +69,9 @@ async def test_restart_device_button( # Availability signalling # Controller disconnects - mock_unifi_websocket(state=WebsocketState.DISCONNECTED) - await hass.async_block_till_done() + await websocket_mock.disconnect() assert hass.states.get("button.switch_restart").state == STATE_UNAVAILABLE # Controller reconnects - mock_unifi_websocket(state=WebsocketState.RUNNING) - await hass.async_block_till_done() + await websocket_mock.reconnect() assert hass.states.get("button.switch_restart").state != STATE_UNAVAILABLE diff --git a/tests/components/unifi/test_controller.py b/tests/components/unifi/test_controller.py index f4738862aef..93b39d2fdf2 100644 --- a/tests/components/unifi/test_controller.py +++ b/tests/components/unifi/test_controller.py @@ -6,7 +6,6 @@ from http import HTTPStatus from unittest.mock import Mock, patch import aiounifi -from aiounifi.websocket import WebsocketState import pytest from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN @@ -28,7 +27,7 @@ from homeassistant.components.unifi.const import ( PLATFORMS, UNIFI_WIRELESS_CLIENTS, ) -from homeassistant.components.unifi.controller import RETRY_TIMER, get_unifi_controller +from homeassistant.components.unifi.controller import get_unifi_controller from homeassistant.components.unifi.errors import AuthenticationRequired, CannotConnect from homeassistant.const import ( CONF_HOST, @@ -44,7 +43,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker DEFAULT_CONFIG_ENTRY_ID = "1" @@ -365,8 +364,8 @@ async def test_reset_fails( async def test_connection_state_signalling( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, - mock_unifi_websocket, mock_device_registry, + websocket_mock, ) -> None: """Verify connection statesignalling and connection state are working.""" client = { @@ -381,21 +380,17 @@ async def test_connection_state_signalling( # Controller is connected assert hass.states.get("device_tracker.client").state == "home" - mock_unifi_websocket(state=WebsocketState.DISCONNECTED) - await hass.async_block_till_done() - + await websocket_mock.disconnect() # Controller is disconnected assert hass.states.get("device_tracker.client").state == "unavailable" - mock_unifi_websocket(state=WebsocketState.RUNNING) - await hass.async_block_till_done() - + await websocket_mock.reconnect() # Controller is once again connected assert hass.states.get("device_tracker.client").state == "home" async def test_reconnect_mechanism( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_unifi_websocket + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, websocket_mock ) -> None: """Verify reconnect prints only on first reconnection try.""" await setup_unifi_integration(hass, aioclient_mock) @@ -403,21 +398,13 @@ async def test_reconnect_mechanism( aioclient_mock.clear_requests() aioclient_mock.get(f"https://{DEFAULT_HOST}:1234/", status=HTTPStatus.BAD_GATEWAY) - mock_unifi_websocket(state=WebsocketState.DISCONNECTED) - await hass.async_block_till_done() - + await websocket_mock.disconnect() assert aioclient_mock.call_count == 0 - new_time = dt_util.utcnow() + timedelta(seconds=RETRY_TIMER) - async_fire_time_changed(hass, new_time) - await hass.async_block_till_done() - + await websocket_mock.reconnect(fail=True) assert aioclient_mock.call_count == 1 - new_time = dt_util.utcnow() + timedelta(seconds=RETRY_TIMER) - async_fire_time_changed(hass, new_time) - await hass.async_block_till_done() - + await websocket_mock.reconnect(fail=True) assert aioclient_mock.call_count == 2 @@ -431,10 +418,7 @@ async def test_reconnect_mechanism( ], ) async def test_reconnect_mechanism_exceptions( - hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, - mock_unifi_websocket, - exception, + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, websocket_mock, exception ) -> None: """Verify async_reconnect calls expected methods.""" await setup_unifi_integration(hass, aioclient_mock) @@ -442,11 +426,9 @@ async def test_reconnect_mechanism_exceptions( with patch("aiounifi.Controller.login", side_effect=exception), patch( "homeassistant.components.unifi.controller.UniFiController.reconnect" ) as mock_reconnect: - mock_unifi_websocket(state=WebsocketState.DISCONNECTED) - await hass.async_block_till_done() + await websocket_mock.disconnect() - new_time = dt_util.utcnow() + timedelta(seconds=RETRY_TIMER) - async_fire_time_changed(hass, new_time) + await websocket_mock.reconnect() mock_reconnect.assert_called_once() diff --git a/tests/components/unifi/test_device_tracker.py b/tests/components/unifi/test_device_tracker.py index 7b939077e48..2680a357d77 100644 --- a/tests/components/unifi/test_device_tracker.py +++ b/tests/components/unifi/test_device_tracker.py @@ -3,7 +3,6 @@ from datetime import timedelta from unittest.mock import patch from aiounifi.models.message import MessageKey -from aiounifi.websocket import WebsocketState from freezegun.api import FrozenDateTimeFactory from homeassistant import config_entries @@ -40,8 +39,8 @@ async def test_no_entities( async def test_tracked_wireless_clients( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, - mock_unifi_websocket, mock_device_registry, + mock_unifi_websocket, ) -> None: """Verify tracking of wireless clients.""" client = { @@ -402,7 +401,7 @@ async def test_remove_clients( async def test_controller_state_change( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, - mock_unifi_websocket, + websocket_mock, mock_device_registry, ) -> None: """Verify entities state reflect on controller becoming unavailable.""" @@ -443,16 +442,12 @@ async def test_controller_state_change( assert hass.states.get("device_tracker.device").state == STATE_HOME # Controller unavailable - mock_unifi_websocket(state=WebsocketState.DISCONNECTED) - await hass.async_block_till_done() - + await websocket_mock.disconnect() assert hass.states.get("device_tracker.client").state == STATE_UNAVAILABLE assert hass.states.get("device_tracker.device").state == STATE_UNAVAILABLE # Controller available - mock_unifi_websocket(state=WebsocketState.RUNNING) - await hass.async_block_till_done() - + await websocket_mock.reconnect() assert hass.states.get("device_tracker.client").state == STATE_NOT_HOME assert hass.states.get("device_tracker.device").state == STATE_HOME @@ -946,7 +941,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_image.py b/tests/components/unifi/test_image.py index 38a8cef43c1..92879f5ad14 100644 --- a/tests/components/unifi/test_image.py +++ b/tests/components/unifi/test_image.py @@ -5,7 +5,6 @@ from datetime import timedelta from http import HTTPStatus from aiounifi.models.message import MessageKey -from aiounifi.websocket import WebsocketState from syrupy.assertion import SnapshotAssertion from homeassistant.components.image import DOMAIN as IMAGE_DOMAIN @@ -65,6 +64,7 @@ async def test_wlan_qr_code( hass_client: ClientSessionGenerator, snapshot: SnapshotAssertion, mock_unifi_websocket, + websocket_mock, ) -> None: """Test the update_clients function when no clients are found.""" await setup_unifi_integration(hass, aioclient_mock, wlans_response=[WLAN]) @@ -121,13 +121,11 @@ async def test_wlan_qr_code( # Availability signalling # Controller disconnects - mock_unifi_websocket(state=WebsocketState.DISCONNECTED) - await hass.async_block_till_done() + await websocket_mock.disconnect() assert hass.states.get("image.ssid_1_qr_code").state == STATE_UNAVAILABLE # Controller reconnects - mock_unifi_websocket(state=WebsocketState.RUNNING) - await hass.async_block_till_done() + await websocket_mock.reconnect() assert hass.states.get("image.ssid_1_qr_code").state != STATE_UNAVAILABLE # WLAN gets disabled diff --git a/tests/components/unifi/test_sensor.py b/tests/components/unifi/test_sensor.py index 7ed87512f2b..b652c38abdb 100644 --- a/tests/components/unifi/test_sensor.py +++ b/tests/components/unifi/test_sensor.py @@ -4,7 +4,6 @@ from datetime import datetime, timedelta from unittest.mock import patch from aiounifi.models.message import MessageKey -from aiounifi.websocket import WebsocketState import pytest from homeassistant.components.device_tracker import DOMAIN as TRACKER_DOMAIN @@ -562,11 +561,14 @@ async def test_remove_sensors( async def test_poe_port_switches( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_unifi_websocket + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + mock_unifi_websocket, + websocket_mock, ) -> 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") @@ -607,16 +609,16 @@ async def test_poe_port_switches( # Availability signalling # Controller disconnects - mock_unifi_websocket(state=WebsocketState.DISCONNECTED) - await hass.async_block_till_done() + await websocket_mock.disconnect() assert ( hass.states.get("sensor.mock_name_port_1_poe_power").state == STATE_UNAVAILABLE ) # Controller reconnects - mock_unifi_websocket(state=WebsocketState.RUNNING) - await hass.async_block_till_done() - assert hass.states.get("sensor.mock_name_port_1_poe_power") + await websocket_mock.reconnect() + assert ( + hass.states.get("sensor.mock_name_port_1_poe_power").state != STATE_UNAVAILABLE + ) # Device gets disabled device_1["disabled"] = True @@ -634,7 +636,10 @@ async def test_poe_port_switches( async def test_wlan_client_sensors( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_unifi_websocket + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + mock_unifi_websocket, + websocket_mock, ) -> None: """Verify that WLAN client sensors are working as expected.""" wireless_client_1 = { @@ -720,13 +725,11 @@ async def test_wlan_client_sensors( # Availability signalling # Controller disconnects - mock_unifi_websocket(state=WebsocketState.DISCONNECTED) - await hass.async_block_till_done() + await websocket_mock.disconnect() assert hass.states.get("sensor.ssid_1").state == STATE_UNAVAILABLE # Controller reconnects - mock_unifi_websocket(state=WebsocketState.RUNNING) - await hass.async_block_till_done() + await websocket_mock.reconnect() assert hass.states.get("sensor.ssid_1").state == "0" # WLAN gets disabled @@ -788,8 +791,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 +812,100 @@ 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) + + 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) + + 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) + 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 d376cab8add..a08cf0be688 100644 --- a/tests/components/unifi/test_switch.py +++ b/tests/components/unifi/test_switch.py @@ -3,10 +3,8 @@ from copy import deepcopy from datetime import timedelta from aiounifi.models.message import MessageKey -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 +32,7 @@ from homeassistant.helpers import entity_registry as er 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 @@ -1007,7 +1000,10 @@ async def test_block_switches( async def test_dpi_switches( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_unifi_websocket + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + mock_unifi_websocket, + websocket_mock, ) -> None: """Test the update_items function with some clients.""" await setup_unifi_integration( @@ -1032,13 +1028,11 @@ async def test_dpi_switches( # Availability signalling # Controller disconnects - mock_unifi_websocket(state=WebsocketState.DISCONNECTED) - await hass.async_block_till_done() + await websocket_mock.disconnect() assert hass.states.get("switch.block_media_streaming").state == STATE_UNAVAILABLE # Controller reconnects - mock_unifi_websocket(state=WebsocketState.RUNNING) - await hass.async_block_till_done() + await websocket_mock.reconnect() assert hass.states.get("switch.block_media_streaming").state == STATE_OFF # Remove app @@ -1134,6 +1128,7 @@ async def test_outlet_switches( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_unifi_websocket, + websocket_mock, entity_id: str, test_data: any, outlet_index: int, @@ -1198,13 +1193,11 @@ async def test_outlet_switches( # Availability signalling # Controller disconnects - mock_unifi_websocket(state=WebsocketState.DISCONNECTED) - await hass.async_block_till_done() + await websocket_mock.disconnect() assert hass.states.get(f"switch.{entity_id}").state == STATE_UNAVAILABLE # Controller reconnects - mock_unifi_websocket(state=WebsocketState.RUNNING) - await hass.async_block_till_done() + await websocket_mock.reconnect() assert hass.states.get(f"switch.{entity_id}").state == STATE_OFF # Device gets disabled @@ -1326,7 +1319,10 @@ async def test_option_remove_switches( async def test_poe_port_switches( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_unifi_websocket + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + mock_unifi_websocket, + websocket_mock, ) -> None: """Test the update_items function with some clients.""" config_entry = await setup_unifi_integration( @@ -1414,13 +1410,11 @@ async def test_poe_port_switches( # Availability signalling # Controller disconnects - mock_unifi_websocket(state=WebsocketState.DISCONNECTED) - await hass.async_block_till_done() + await websocket_mock.disconnect() assert hass.states.get("switch.mock_name_port_1_poe").state == STATE_UNAVAILABLE # Controller reconnects - mock_unifi_websocket(state=WebsocketState.RUNNING) - await hass.async_block_till_done() + await websocket_mock.reconnect() assert hass.states.get("switch.mock_name_port_1_poe").state == STATE_OFF # Device gets disabled @@ -1436,40 +1430,11 @@ 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 + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + mock_unifi_websocket, + websocket_mock, ) -> None: """Test control of UniFi WLAN availability.""" config_entry = await setup_unifi_integration( @@ -1526,18 +1491,19 @@ async def test_wlan_switches( # Availability signalling # Controller disconnects - mock_unifi_websocket(state=WebsocketState.DISCONNECTED) - await hass.async_block_till_done() + await websocket_mock.disconnect() assert hass.states.get("switch.ssid_1").state == STATE_UNAVAILABLE # Controller reconnects - mock_unifi_websocket(state=WebsocketState.RUNNING) - await hass.async_block_till_done() + await websocket_mock.reconnect() assert hass.states.get("switch.ssid_1").state == STATE_OFF async def test_port_forwarding_switches( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_unifi_websocket + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + mock_unifi_websocket, + websocket_mock, ) -> None: """Test control of UniFi port forwarding.""" _data = { @@ -1608,13 +1574,11 @@ async def test_port_forwarding_switches( # Availability signalling # Controller disconnects - mock_unifi_websocket(state=WebsocketState.DISCONNECTED) - await hass.async_block_till_done() + await websocket_mock.disconnect() assert hass.states.get("switch.unifi_network_plex").state == STATE_UNAVAILABLE # Controller reconnects - mock_unifi_websocket(state=WebsocketState.RUNNING) - await hass.async_block_till_done() + await websocket_mock.reconnect() assert hass.states.get("switch.unifi_network_plex").state == STATE_OFF # Remove entity on deleted message diff --git a/tests/components/unifi/test_update.py b/tests/components/unifi/test_update.py index e59eca371d6..4f7a3dfe11d 100644 --- a/tests/components/unifi/test_update.py +++ b/tests/components/unifi/test_update.py @@ -2,7 +2,6 @@ from copy import deepcopy from aiounifi.models.message import MessageKey -from aiounifi.websocket import WebsocketState from yarl import URL from homeassistant.components.unifi.const import CONF_SITE_ID @@ -185,26 +184,18 @@ async def test_install( async def test_controller_state_change( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_unifi_websocket + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, websocket_mock ) -> None: """Verify entities state reflect on controller becoming unavailable.""" - await setup_unifi_integration( - hass, - aioclient_mock, - devices_response=[DEVICE_1], - ) + await setup_unifi_integration(hass, aioclient_mock, devices_response=[DEVICE_1]) assert len(hass.states.async_entity_ids(UPDATE_DOMAIN)) == 1 assert hass.states.get("update.device_1").state == STATE_ON # Controller unavailable - mock_unifi_websocket(state=WebsocketState.DISCONNECTED) - await hass.async_block_till_done() - + await websocket_mock.disconnect() assert hass.states.get("update.device_1").state == STATE_UNAVAILABLE # Controller available - mock_unifi_websocket(state=WebsocketState.RUNNING) - await hass.async_block_till_done() - + await websocket_mock.reconnect() assert hass.states.get("update.device_1").state == STATE_ON diff --git a/tests/components/venstar/__init__.py b/tests/components/venstar/__init__.py index fa35dd88379..f91f8f28bdf 100644 --- a/tests/components/venstar/__init__.py +++ b/tests/components/venstar/__init__.py @@ -18,7 +18,8 @@ class VenstarColorTouchMock: """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 119443962fc..849c13d4396 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 @@ ZEROCONF_HOST = HOST.split(":")[0] 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 4c47a0c5640..578d79fcba0 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 3d2ef0cf568..41efd8af00c 100644 --- a/tests/components/vodafone_station/test_config_flow.py +++ b/tests/components/vodafone_station/test_config_flow.py @@ -52,6 +52,8 @@ async def test_user(hass: HomeAssistant) -> None: [ (aiovodafone_exceptions.CannotConnect, "cannot_connect"), (aiovodafone_exceptions.CannotAuthenticate, "invalid_auth"), + (aiovodafone_exceptions.AlreadyLogged, "already_logged"), + (aiovodafone_exceptions.ModelNotSupported, "model_not_supported"), (ConnectionResetError, "unknown"), ], ) @@ -152,6 +154,7 @@ async def test_reauth_successful(hass: HomeAssistant) -> None: [ (aiovodafone_exceptions.CannotConnect, "cannot_connect"), (aiovodafone_exceptions.CannotAuthenticate, "invalid_auth"), + (aiovodafone_exceptions.AlreadyLogged, "already_logged"), (ConnectionResetError, "unknown"), ], ) diff --git a/tests/components/voip/test_voip.py b/tests/components/voip/test_voip.py index 361e4e7f0e2..f82a00087c6 100644 --- a/tests/components/voip/test_voip.py +++ b/tests/components/voip/test_voip.py @@ -21,7 +21,7 @@ async def test_pipeline( """Test that pipeline function is called from RTP protocol.""" assert await async_setup_component(hass, "voip", {}) - def is_speech(self, chunk, sample_rate): + def is_speech(self, chunk): """Anything non-zero is speech.""" return sum(chunk) > 0 @@ -76,7 +76,7 @@ async def test_pipeline( return ("mp3", b"") with patch( - "webrtcvad.Vad.is_speech", + "homeassistant.components.assist_pipeline.vad.WebRtcVad.is_speech", new=is_speech, ), patch( "homeassistant.components.voip.voip.async_pipeline_from_audio_stream", @@ -210,7 +210,7 @@ async def test_tts_timeout( """Test that TTS will time out based on its length.""" assert await async_setup_component(hass, "voip", {}) - def is_speech(self, chunk, sample_rate): + def is_speech(self, chunk): """Anything non-zero is speech.""" return sum(chunk) > 0 @@ -269,7 +269,7 @@ async def test_tts_timeout( return ("raw", bytes(0)) with patch( - "webrtcvad.Vad.is_speech", + "homeassistant.components.assist_pipeline.vad.WebRtcVad.is_speech", new=is_speech, ), patch( "homeassistant.components.voip.voip.async_pipeline_from_audio_stream", diff --git a/tests/components/volumio/test_config_flow.py b/tests/components/volumio/test_config_flow.py index 5d734d1b2d5..841b558eba3 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_CONNECTION = { 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/wake_word/snapshots/test_init.ambr b/tests/components/wake_word/snapshots/test_init.ambr deleted file mode 100644 index cf7c09cd730..00000000000 --- a/tests/components/wake_word/snapshots/test_init.ambr +++ /dev/null @@ -1,14 +0,0 @@ -# serializer version: 1 -# name: test_detected_entity - None -# --- -# name: test_ws_detect - dict({ - 'event': dict({ - 'timestamp': 2048.0, - 'ww_id': 'test_ww', - }), - 'id': 1, - 'type': 'event', - }) -# --- diff --git a/tests/components/wake_word/test_init.py b/tests/components/wake_word/test_init.py index d37cb3aa540..5d1cc5a4b3f 100644 --- a/tests/components/wake_word/test_init.py +++ b/tests/components/wake_word/test_init.py @@ -2,8 +2,8 @@ from collections.abc import AsyncIterable, Generator from pathlib import Path +from freezegun import freeze_time import pytest -from syrupy.assertion import SnapshotAssertion from homeassistant.components import wake_word from homeassistant.config_entries import ConfigEntry, ConfigEntryState, ConfigFlow @@ -22,6 +22,7 @@ from tests.common import ( mock_platform, mock_restore_cache, ) +from tests.typing import WebSocketGenerator TEST_DOMAIN = "test" @@ -39,16 +40,22 @@ class MockProviderEntity(wake_word.WakeWordDetectionEntity): @property def supported_wake_words(self) -> list[wake_word.WakeWord]: """Return a list of supported wake words.""" - return [wake_word.WakeWord(ww_id="test_ww", name="Test Wake Word")] + return [ + wake_word.WakeWord(id="test_ww", name="Test Wake Word"), + wake_word.WakeWord(id="test_ww_2", name="Test Wake Word 2"), + ] async def _async_process_audio_stream( - self, stream: AsyncIterable[tuple[bytes, int]] + self, stream: AsyncIterable[tuple[bytes, int]], wake_word_id: str | None ) -> wake_word.DetectionResult | None: """Try to detect wake word(s) in an audio stream with timestamps.""" + if wake_word_id is None: + wake_word_id = self.supported_wake_words[0].id + async for _chunk, timestamp in stream: if timestamp >= 2000: return wake_word.DetectionResult( - ww_id=self.supported_wake_words[0].ww_id, timestamp=timestamp + wake_word_id=wake_word_id, timestamp=timestamp ) # Not detected @@ -148,11 +155,20 @@ async def test_config_entry_unload( assert config_entry.state == ConfigEntryState.NOT_LOADED +@freeze_time("2023-06-22 10:30:00+00:00") +@pytest.mark.parametrize( + ("wake_word_id", "expected_ww"), + [ + (None, "test_ww"), + ("test_ww_2", "test_ww_2"), + ], +) async def test_detected_entity( hass: HomeAssistant, tmp_path: Path, setup: MockProviderEntity, - snapshot: SnapshotAssertion, + wake_word_id: str | None, + expected_ww: str, ) -> None: """Test successful detection through entity.""" @@ -164,11 +180,12 @@ async def test_detected_entity( # Need 2 seconds to trigger state = setup.state - result = await setup.async_process_audio_stream(three_second_stream()) - assert result == wake_word.DetectionResult("test_ww", 2048) + assert state is None + result = await setup.async_process_audio_stream(three_second_stream(), wake_word_id) + assert result == wake_word.DetectionResult(expected_ww, 2048) assert state != setup.state - assert state == snapshot + assert setup.state == "2023-06-22T10:30:00+00:00" async def test_not_detected_entity( @@ -184,7 +201,7 @@ async def test_not_detected_entity( # Need 2 seconds to trigger state = setup.state - result = await setup.async_process_audio_stream(one_second_stream()) + result = await setup.async_process_audio_stream(one_second_stream(), None) assert result is None # State should only change when there's a detection @@ -192,20 +209,20 @@ async def test_not_detected_entity( async def test_default_engine_none(hass: HomeAssistant, tmp_path: Path) -> None: - """Test async_default_engine.""" + """Test async_default_entity.""" assert await async_setup_component(hass, wake_word.DOMAIN, {wake_word.DOMAIN: {}}) await hass.async_block_till_done() - assert wake_word.async_default_engine(hass) is None + assert wake_word.async_default_entity(hass) is None async def test_default_engine_entity( hass: HomeAssistant, tmp_path: Path, mock_provider_entity: MockProviderEntity ) -> None: - """Test async_default_engine.""" + """Test async_default_entity.""" await mock_config_entry_setup(hass, tmp_path, mock_provider_entity) - assert wake_word.async_default_engine(hass) == f"{wake_word.DOMAIN}.{TEST_DOMAIN}" + assert wake_word.async_default_entity(hass) == f"{wake_word.DOMAIN}.{TEST_DOMAIN}" async def test_get_engine_entity( @@ -244,3 +261,50 @@ async def test_entity_attributes( ) -> None: """Test that the provider entity attributes match expectations.""" assert mock_provider_entity.entity_category == EntityCategory.DIAGNOSTIC + + +async def test_list_wake_words( + hass: HomeAssistant, + setup: MockProviderEntity, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test that the list_wake_words websocket command works.""" + client = await hass_ws_client(hass) + await client.send_json( + { + "id": 5, + "type": "wake_word/info", + "entity_id": setup.entity_id, + } + ) + + msg = await client.receive_json() + + assert msg["success"] + assert msg["result"] == { + "wake_words": [ + {"id": "test_ww", "name": "Test Wake Word"}, + {"id": "test_ww_2", "name": "Test Wake Word 2"}, + ] + } + + +async def test_list_wake_words_unknown_entity( + hass: HomeAssistant, + setup: MockProviderEntity, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test that the list_wake_words websocket command works.""" + client = await hass_ws_client(hass) + await client.send_json( + { + "id": 5, + "type": "wake_word/info", + "entity_id": "wake_word.blah", + } + ) + + msg = await client.receive_json() + + assert not msg["success"] + assert msg["error"] == {"code": "not_found", "message": "Entity not found"} diff --git a/tests/components/wallbox/const.py b/tests/components/wallbox/const.py index 1f052643696..477fb10d292 100644 --- a/tests/components/wallbox/const.py +++ b/tests/components/wallbox/const.py @@ -5,9 +5,9 @@ TTL = "ttl" ERROR = "error" STATUS = "status" -MOCK_NUMBER_ENTITY_ID = "number.mock_title_max_charging_current" -MOCK_LOCK_ENTITY_ID = "lock.mock_title_locked_unlocked" -MOCK_SENSOR_CHARGING_SPEED_ID = "sensor.mock_title_charging_speed" -MOCK_SENSOR_CHARGING_POWER_ID = "sensor.mock_title_charging_power" -MOCK_SENSOR_MAX_AVAILABLE_POWER = "sensor.mock_title_max_available_power" -MOCK_SWITCH_ENTITY_ID = "switch.mock_title_pause_resume" +MOCK_NUMBER_ENTITY_ID = "number.wallbox_wallboxname_maximum_charging_current" +MOCK_LOCK_ENTITY_ID = "lock.wallbox_wallboxname_lock" +MOCK_SENSOR_CHARGING_SPEED_ID = "sensor.wallbox_wallboxname_charging_speed" +MOCK_SENSOR_CHARGING_POWER_ID = "sensor.wallbox_wallboxname_charging_power" +MOCK_SENSOR_MAX_AVAILABLE_POWER = "sensor.wallbox_wallboxname_max_available_power" +MOCK_SWITCH_ENTITY_ID = "switch.wallbox_wallboxname_pause_resume" diff --git a/tests/components/wallbox/test_sensor.py b/tests/components/wallbox/test_sensor.py index a6bda688997..ca12e1d9ac3 100644 --- a/tests/components/wallbox/test_sensor.py +++ b/tests/components/wallbox/test_sensor.py @@ -21,11 +21,11 @@ async def test_wallbox_sensor_class( state = hass.states.get(MOCK_SENSOR_CHARGING_POWER_ID) assert state.attributes[CONF_UNIT_OF_MEASUREMENT] == UnitOfPower.KILO_WATT - assert state.name == "Mock Title Charging Power" + assert state.name == "Wallbox WallboxName Charging power" state = hass.states.get(MOCK_SENSOR_CHARGING_SPEED_ID) assert state.attributes[CONF_ICON] == "mdi:speedometer" - assert state.name == "Mock Title Charging Speed" + assert state.name == "Wallbox WallboxName Charging speed" # Test round with precision '0' works state = hass.states.get(MOCK_SENSOR_MAX_AVAILABLE_POWER) diff --git a/tests/components/waqi/__init__.py b/tests/components/waqi/__init__.py new file mode 100644 index 00000000000..b6f36680ee3 --- /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 00000000000..176c1e27d8f --- /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 00000000000..49f1184822f --- /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 00000000000..65da5abc09a --- /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 00000000000..7a95e000d82 --- /dev/null +++ b/tests/components/waqi/test_config_flow.py @@ -0,0 +1,275 @@ +"""Test the World Air Quality Index (WAQI) config flow.""" +import json +from typing import Any +from unittest.mock import AsyncMock, patch + +from aiowaqi import WAQIAirQuality, WAQIAuthenticationError, WAQIConnectionError +import pytest + +from homeassistant import config_entries +from homeassistant.components.waqi.config_flow import CONF_MAP +from homeassistant.components.waqi.const import CONF_STATION_NUMBER, DOMAIN +from homeassistant.const import ( + CONF_API_KEY, + CONF_LATITUDE, + CONF_LOCATION, + CONF_LONGITUDE, + CONF_METHOD, +) +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") + + +@pytest.mark.parametrize( + ("method", "payload"), + [ + ( + CONF_MAP, + { + CONF_LOCATION: {CONF_LATITUDE: 50.0, CONF_LONGITUDE: 10.0}, + }, + ), + ( + CONF_STATION_NUMBER, + { + CONF_STATION_NUMBER: 4584, + }, + ), + ], +) +async def test_full_map_flow( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + method: str, + payload: dict[str, Any], +) -> 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_ip", + return_value=WAQIAirQuality.from_dict( + json.loads(load_fixture("waqi/air_quality_sensor.json")) + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "asd", CONF_METHOD: method}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == method + + with patch( + "aiowaqi.WAQIClient.authenticate", + ), patch( + "aiowaqi.WAQIClient.get_by_coordinates", + return_value=WAQIAirQuality.from_dict( + json.loads(load_fixture("waqi/air_quality_sensor.json")) + ), + ), patch( + "aiowaqi.WAQIClient.get_by_station_number", + return_value=WAQIAirQuality.from_dict( + json.loads(load_fixture("waqi/air_quality_sensor.json")) + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + payload, + ) + 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_ip", + side_effect=exception, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "asd", CONF_METHOD: CONF_MAP}, + ) + 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_ip", + return_value=WAQIAirQuality.from_dict( + json.loads(load_fixture("waqi/air_quality_sensor.json")) + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "asd", CONF_METHOD: CONF_MAP}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "map" + + with patch( + "aiowaqi.WAQIClient.authenticate", + ), patch( + "aiowaqi.WAQIClient.get_by_coordinates", + return_value=WAQIAirQuality.from_dict( + 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}, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + + +@pytest.mark.parametrize( + ("method", "payload", "exception", "error"), + [ + ( + CONF_MAP, + { + CONF_LOCATION: {CONF_LATITUDE: 50.0, CONF_LONGITUDE: 10.0}, + }, + WAQIConnectionError(), + "cannot_connect", + ), + ( + CONF_MAP, + { + CONF_LOCATION: {CONF_LATITUDE: 50.0, CONF_LONGITUDE: 10.0}, + }, + Exception(), + "unknown", + ), + ( + CONF_STATION_NUMBER, + { + CONF_STATION_NUMBER: 4584, + }, + WAQIConnectionError(), + "cannot_connect", + ), + ( + CONF_STATION_NUMBER, + { + CONF_STATION_NUMBER: 4584, + }, + Exception(), + "unknown", + ), + ], +) +async def test_error_in_second_step( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + method: str, + payload: dict[str, Any], + exception: Exception, + error: str, +) -> 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_ip", + return_value=WAQIAirQuality.from_dict( + json.loads(load_fixture("waqi/air_quality_sensor.json")) + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "asd", CONF_METHOD: method}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == method + + with patch( + "aiowaqi.WAQIClient.authenticate", + ), patch( + "aiowaqi.WAQIClient.get_by_coordinates", side_effect=exception + ), patch("aiowaqi.WAQIClient.get_by_station_number", side_effect=exception): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + payload, + ) + 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.from_dict( + json.loads(load_fixture("waqi/air_quality_sensor.json")) + ), + ), patch( + "aiowaqi.WAQIClient.get_by_station_number", + return_value=WAQIAirQuality.from_dict( + json.loads(load_fixture("waqi/air_quality_sensor.json")) + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + payload, + ) + 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 diff --git a/tests/components/waqi/test_sensor.py b/tests/components/waqi/test_sensor.py new file mode 100644 index 00000000000..7feb37a1b09 --- /dev/null +++ b/tests/components/waqi/test_sensor.py @@ -0,0 +1,151 @@ +"""Test the World Air Quality Index (WAQI) sensor.""" +import json +from unittest.mock import patch + +from aiowaqi import WAQIAirQuality, WAQIError, WAQISearchResult + +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +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 entity_registry as er, 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.from_dict(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.from_dict( + 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.from_dict( + 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_id_migration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test migrating unique id for original sensor.""" + mock_config_entry.add_to_hass(hass) + entity_registry = er.async_get(hass) + entity_registry.async_get_or_create( + SENSOR_DOMAIN, DOMAIN, 4584, config_entry=mock_config_entry + ) + with patch( + "aiowaqi.WAQIClient.get_by_station_number", + return_value=WAQIAirQuality.from_dict( + json.loads(load_fixture("waqi/air_quality_sensor.json")) + ), + ): + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + entities = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + assert len(entities) == 1 + assert hass.states.get("sensor.waqi_4584") + assert hass.states.get("sensor.waqi_de_jongweg_utrecht") is None + assert entities[0].unique_id == "4584_air_quality" + + +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.from_dict( + 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/weatherflow/__init__.py b/tests/components/weatherflow/__init__.py new file mode 100644 index 00000000000..e7dd3dc0958 --- /dev/null +++ b/tests/components/weatherflow/__init__.py @@ -0,0 +1 @@ +"""Tests for the WeatherFlow integration.""" diff --git a/tests/components/weatherflow/conftest.py b/tests/components/weatherflow/conftest.py new file mode 100644 index 00000000000..0bf6b69b9a7 --- /dev/null +++ b/tests/components/weatherflow/conftest.py @@ -0,0 +1,79 @@ +"""Fixtures for Weatherflow integration tests.""" +import asyncio +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest +from pyweatherflowudp.client import EVENT_DEVICE_DISCOVERED +from pyweatherflowudp.device import WeatherFlowDevice + +from homeassistant.components.weatherflow.const import DOMAIN + +from tests.common import MockConfigEntry, load_json_object_fixture + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.weatherflow.async_setup_entry", return_value=True + ) as mock_setup: + yield mock_setup + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return a mock config entry.""" + return MockConfigEntry(domain=DOMAIN, data={}) + + +@pytest.fixture +def mock_has_devices() -> Generator[AsyncMock, None, None]: + """Return a mock has_devices function.""" + with patch( + "homeassistant.components.weatherflow.config_flow.WeatherFlowListener.on", + return_value=True, + ) as mock_has_devices: + yield mock_has_devices + + +@pytest.fixture +def mock_stop() -> Generator[AsyncMock, None, None]: + """Return a fixture to handle the stop of udp.""" + + async def mock_stop_listening(self): + self._udp_task.cancel() + + with patch( + "homeassistant.components.weatherflow.config_flow.WeatherFlowListener.stop_listening", + autospec=True, + side_effect=mock_stop_listening, + ) as mock_function: + yield mock_function + + +@pytest.fixture +def mock_start() -> Generator[AsyncMock, None, None]: + """Return fixture for starting upd.""" + + device = WeatherFlowDevice( + serial_number="HB-00000001", + data=load_json_object_fixture("weatherflow/device.json"), + ) + + async def device_discovery_task(self): + await asyncio.gather( + await asyncio.sleep(0.1), self.emit(EVENT_DEVICE_DISCOVERED, "HB-00000001") + ) + + async def mock_start_listening(self): + """Mock listening function.""" + self._devices["HB-00000001"] = device + self._udp_task = asyncio.create_task(device_discovery_task(self)) + + with patch( + "homeassistant.components.weatherflow.config_flow.WeatherFlowListener.start_listening", + autospec=True, + side_effect=mock_start_listening, + ) as mock_function: + yield mock_function diff --git a/tests/components/weatherflow/fixtures/device.json b/tests/components/weatherflow/fixtures/device.json new file mode 100644 index 00000000000..a9653c71cb0 --- /dev/null +++ b/tests/components/weatherflow/fixtures/device.json @@ -0,0 +1,13 @@ +{ + "serial_number": "ST-00000001", + "type": "device_status", + "hub_sn": "HB-00000001", + "timestamp": 1510855923, + "uptime": 2189, + "voltage": 3.5, + "firmware_revision": 17, + "rssi": -17, + "hub_rssi": -87, + "sensor_status": 0, + "debug": 0 +} diff --git a/tests/components/weatherflow/test_config_flow.py b/tests/components/weatherflow/test_config_flow.py new file mode 100644 index 00000000000..4188c737230 --- /dev/null +++ b/tests/components/weatherflow/test_config_flow.py @@ -0,0 +1,91 @@ +"""Tests for WeatherFlow.""" + +import asyncio +from unittest.mock import AsyncMock, patch + +import pytest +from pyweatherflowudp.errors import AddressInUseError + +from homeassistant import config_entries +from homeassistant.components.weatherflow.const import ( + DOMAIN, + ERROR_MSG_ADDRESS_IN_USE, + ERROR_MSG_CANNOT_CONNECT, + ERROR_MSG_NO_DEVICE_FOUND, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_single_instance( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_has_devices: AsyncMock, +) -> None: + """Test more than one instance.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "single_instance_allowed" + + +async def test_devices_with_mocks( + hass: HomeAssistant, + mock_start: AsyncMock, + mock_stop: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test getting user input.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + + await hass.async_block_till_done() + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == {} + + +@pytest.mark.parametrize( + ("exception", "error_msg"), + [ + (asyncio.TimeoutError, ERROR_MSG_NO_DEVICE_FOUND), + (asyncio.exceptions.CancelledError, ERROR_MSG_CANNOT_CONNECT), + (AddressInUseError, ERROR_MSG_ADDRESS_IN_USE), + ], +) +async def test_devices_with_various_mocks_errors( + hass: HomeAssistant, + mock_start: AsyncMock, + mock_stop: AsyncMock, + mock_setup_entry: AsyncMock, + exception: Exception, + error_msg: str, +) -> None: + """Test the various on error states - then finally complete the test.""" + + with patch( + "homeassistant.components.weatherflow.config_flow.WeatherFlowListener.on", + side_effect=exception, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + assert result["errors"]["base"] == error_msg + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + await hass.async_block_till_done() + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == {} diff --git a/tests/components/weatherkit/__init__.py b/tests/components/weatherkit/__init__.py new file mode 100644 index 00000000000..5118c44c45b --- /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 00000000000..7cfa2f7eef5 --- /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 00000000000..c2d619d85d8 --- /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 00000000000..63321b5a813 --- /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 00000000000..3b6cf76a3d5 --- /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 00000000000..f619ace237a --- /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 00000000000..d71ecbda1b0 --- /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 00000000000..fabd3aab572 --- /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 b1b2027c65d..f200c44acca 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -20,7 +20,7 @@ from homeassistant.components.websocket_api.const import FEATURE_COALESCE_MESSAG 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 @@ -92,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 = [] @@ -121,7 +123,9 @@ async def test_fire_event(hass: HomeAssistant, websocket_client) -> None: 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 = [] @@ -149,7 +153,9 @@ async def test_fire_event_without_data(hass: HomeAssistant, websocket_client) -> 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") @@ -179,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( @@ -256,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") @@ -316,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( { @@ -433,7 +443,9 @@ async def test_call_service_schema_validation_error( 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 @@ -526,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") @@ -545,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"}) @@ -557,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"}) @@ -584,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"}) @@ -637,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 = [] @@ -668,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")}) @@ -691,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 = [] @@ -728,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 = [] @@ -754,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.""" @@ -871,7 +897,9 @@ async def test_subscribe_entities_with_unserializable_state( 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.""" @@ -1037,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.""" @@ -1376,7 +1406,7 @@ async def test_render_template_with_error( ) async def test_render_template_with_timeout_and_error( hass: HomeAssistant, - websocket_client, + websocket_client: MockHAClientWebSocket, caplog: pytest.LogCaptureFixture, template: str, expected_events: list[dict[str, str]], @@ -1592,7 +1622,7 @@ async def test_render_template_strict_with_timeout_and_error_2( ) async def test_render_template_error_in_template_code( hass: HomeAssistant, - websocket_client, + websocket_client: MockHAClientWebSocket, caplog: pytest.LogCaptureFixture, template: str, expected_events_1: list[dict[str, str]], @@ -1691,7 +1721,9 @@ async def test_render_template_error_in_template_code_2( 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. @@ -1815,7 +1847,9 @@ async def test_render_template_with_delayed_error_2( async def test_render_template_with_timeout( - hass: HomeAssistant, websocket_client, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + websocket_client: MockHAClientWebSocket, + caplog: pytest.LogCaptureFixture, ) -> None: """Test a template that will timeout.""" @@ -1859,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") @@ -1897,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") @@ -1924,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) @@ -1941,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, - }, + "test_domain.entity_1": {"domain": "test_platform"}, + "test_domain.entity_2": {"domain": "test_platform"}, } - # 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, - }, - } - - # 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( @@ -2025,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()) @@ -2098,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") @@ -2158,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"} @@ -2307,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( @@ -2329,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] = { @@ -2365,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}) @@ -2404,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}) @@ -2416,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( @@ -2488,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"}) @@ -2530,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 c1caac222a5..459deaae4c5 100644 --- a/tests/components/withings/__init__.py +++ b/tests/components/withings/__init__.py @@ -1 +1,55 @@ """Tests for the withings component.""" +from dataclasses import dataclass +from typing import Any +from urllib.parse import urlparse + +from aiohttp.test_utils import TestClient + +from homeassistant.components.webhook import async_generate_url +from homeassistant.config import async_process_ha_core_config +from homeassistant.core import HomeAssistant + +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: TestClient +) -> 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 = await resp.json() + resp.close() + + return WebhookResponse(message=data["message"], message_code=data["code"]) + + +async def setup_integration( + hass: HomeAssistant, config_entry: MockConfigEntry, enable_webhooks: bool = True +) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + if enable_webhooks: + await async_process_ha_core_config( + hass, + {"external_url": "https://example.local:8123"}, + ) + + await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/withings/common.py b/tests/components/withings/common.py deleted file mode 100644 index e5c246dc95e..00000000000 --- a/tests/components/withings/common.py +++ /dev/null @@ -1,336 +0,0 @@ -"""Common data for for the withings component tests.""" -from __future__ import annotations - -from dataclasses import dataclass -from http import HTTPStatus -from unittest.mock import MagicMock -from urllib.parse import urlparse - -from aiohttp.test_utils import TestClient -import arrow -from withings_api.common import ( - MeasureGetMeasResponse, - NotifyAppli, - NotifyListResponse, - SleepGetSummaryResponse, - UserGetDeviceResponse, -) - -from homeassistant import data_entry_flow -import homeassistant.components.api as api -from homeassistant.components.homeassistant import DOMAIN as HA_DOMAIN -import homeassistant.components.webhook as webhook -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.config import async_process_ha_core_config -from homeassistant.config_entries import SOURCE_USER, ConfigEntry -from homeassistant.const import ( - CONF_CLIENT_ID, - CONF_CLIENT_SECRET, - CONF_EXTERNAL_URL, - CONF_UNIT_SYSTEM, - CONF_UNIT_SYSTEM_METRIC, -) -from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_entry_oauth2_flow, entity_registry as er -from homeassistant.helpers.config_entry_oauth2_flow import AUTH_CALLBACK_PATH -from homeassistant.setup import async_setup_component -from homeassistant.util import dt as dt_util - -from tests.common import MockConfigEntry -from tests.test_util.aiohttp import AiohttpClientMocker - - -@dataclass -class ProfileConfig: - """Data representing a user profile.""" - - profile: str - user_id: int - api_response_user_get_device: UserGetDeviceResponse | Exception - api_response_measure_get_meas: MeasureGetMeasResponse | Exception - api_response_sleep_get_summary: SleepGetSummaryResponse | Exception - api_response_notify_list: NotifyListResponse | Exception - api_response_notify_revoke: Exception | None - - -def new_profile_config( - profile: str, - user_id: int, - api_response_user_get_device: UserGetDeviceResponse | Exception | None = None, - api_response_measure_get_meas: MeasureGetMeasResponse | Exception | None = None, - api_response_sleep_get_summary: SleepGetSummaryResponse | Exception | None = None, - api_response_notify_list: NotifyListResponse | Exception | None = None, - api_response_notify_revoke: Exception | None = None, -) -> ProfileConfig: - """Create a new profile config immutable object.""" - return ProfileConfig( - profile=profile, - user_id=user_id, - api_response_user_get_device=api_response_user_get_device - or UserGetDeviceResponse(devices=[]), - api_response_measure_get_meas=api_response_measure_get_meas - or MeasureGetMeasResponse( - measuregrps=[], - more=False, - offset=0, - timezone=dt_util.UTC, - updatetime=arrow.get(12345), - ), - api_response_sleep_get_summary=api_response_sleep_get_summary - or SleepGetSummaryResponse(more=False, offset=0, series=[]), - api_response_notify_list=api_response_notify_list - or NotifyListResponse(profiles=[]), - api_response_notify_revoke=api_response_notify_revoke, - ) - - -@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.""" - - def __init__( - self, - hass: HomeAssistant, - api_class_mock: MagicMock, - hass_client_no_auth, - aioclient_mock: AiohttpClientMocker, - ) -> None: - """Initialize the object.""" - self._hass = hass - self._api_class_mock = api_class_mock - self._hass_client = hass_client_no_auth - self._aioclient_mock = aioclient_mock - self._client_id = None - self._client_secret = None - self._profile_configs: tuple[ProfileConfig, ...] = () - - async def configure_component( - self, - client_id: str = "my_client_id", - client_secret: str = "my_client_secret", - profile_configs: tuple[ProfileConfig, ...] = (), - ) -> None: - """Configure the wihings component.""" - self._client_id = client_id - self._client_secret = client_secret - self._profile_configs = profile_configs - - hass_config = { - "homeassistant": { - CONF_UNIT_SYSTEM: CONF_UNIT_SYSTEM_METRIC, - CONF_EXTERNAL_URL: "http://127.0.0.1:8080/", - }, - api.DOMAIN: {}, - const.DOMAIN: { - CONF_CLIENT_ID: self._client_id, - CONF_CLIENT_SECRET: self._client_secret, - const.CONF_USE_WEBHOOK: True, - }, - } - - await async_process_ha_core_config(self._hass, hass_config.get("homeassistant")) - assert await async_setup_component(self._hass, HA_DOMAIN, {}) - assert await async_setup_component(self._hass, webhook.DOMAIN, hass_config) - - assert await async_setup_component(self._hass, const.DOMAIN, hass_config) - await self._hass.async_block_till_done() - - @staticmethod - def _setup_api_method(api_method, value) -> None: - if isinstance(value, Exception): - api_method.side_effect = value - else: - api_method.return_value = value - - async def setup_profile(self, user_id: int) -> ConfigEntryWithingsApi: - """Set up a user profile through config flows.""" - profile_config = next( - iter( - [ - profile_config - for profile_config in self._profile_configs - if profile_config.user_id == user_id - ] - ) - ) - - api_mock: ConfigEntryWithingsApi = MagicMock(spec=ConfigEntryWithingsApi) - api_mock.config_entry = MockConfigEntry( - domain=const.DOMAIN, - data={"profile": profile_config.profile}, - ) - ComponentFactory._setup_api_method( - api_mock.user_get_device, profile_config.api_response_user_get_device - ) - ComponentFactory._setup_api_method( - api_mock.sleep_get_summary, profile_config.api_response_sleep_get_summary - ) - ComponentFactory._setup_api_method( - api_mock.measure_get_meas, profile_config.api_response_measure_get_meas - ) - ComponentFactory._setup_api_method( - api_mock.notify_list, profile_config.api_response_notify_list - ) - ComponentFactory._setup_api_method( - api_mock.notify_revoke, profile_config.api_response_notify_revoke - ) - - self._api_class_mock.reset_mocks() - self._api_class_mock.return_value = api_mock - - # Get the withings config flow. - result = await self._hass.config_entries.flow.async_init( - const.DOMAIN, context={"source": SOURCE_USER} - ) - assert result - - state = config_entry_oauth2_flow._encode_jwt( - self._hass, - { - "flow_id": result["flow_id"], - "redirect_uri": "https://example.com/auth/external/callback", - }, - ) - assert result["type"] == data_entry_flow.FlowResultType.EXTERNAL_STEP - assert result["url"] == ( - "https://account.withings.com/oauth2_user/authorize2?" - f"response_type=code&client_id={self._client_id}&" - "redirect_uri=https://example.com/auth/external/callback&" - f"state={state}" - "&scope=user.info,user.metrics,user.activity,user.sleepevents" - ) - - # Simulate user being redirected from withings site. - client: TestClient = await self._hass_client() - resp = await client.get(f"{AUTH_CALLBACK_PATH}?code=abcd&state={state}") - assert resp.status == HTTPStatus.OK - assert resp.headers["content-type"] == "text/html; charset=utf-8" - - self._aioclient_mock.clear_requests() - self._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": profile_config.user_id, - }, - }, - ) - - # Present user with a list of profiles to choose from. - result = await self._hass.config_entries.flow.async_configure(result["flow_id"]) - assert result.get("type") == "form" - assert result.get("step_id") == "profile" - assert "profile" in result.get("data_schema").schema - - # Provide the user profile. - result = await self._hass.config_entries.flow.async_configure( - result["flow_id"], {const.PROFILE: profile_config.profile} - ) - - # Finish the config flow by calling it again. - assert result.get("type") == "create_entry" - assert result.get("result") - config_data = result.get("result").data - assert config_data.get(const.PROFILE) == profile_config.profile - assert config_data.get("auth_implementation") == const.DOMAIN - assert config_data.get("token") - - # Wait for remaining tasks to complete. - await self._hass.async_block_till_done() - - # Mock the webhook. - data_manager = get_data_manager_by_user_id(self._hass, user_id) - self._aioclient_mock.clear_requests() - self._aioclient_mock.request( - "HEAD", - data_manager.webhook_config.url, - ) - - return self._api_class_mock.return_value - - async def call_webhook(self, user_id: int, appli: NotifyAppli) -> WebhookResponse: - """Call the webhook to notify of data changes.""" - client: TestClient = await self._hass_client() - data_manager = get_data_manager_by_user_id(self._hass, user_id) - - resp = await client.post( - urlparse(data_manager.webhook_config.url).path, - data={"userid": user_id, "appli": appli.value}, - ) - - # Wait for remaining tasks to complete. - await self._hass.async_block_till_done() - - data = await resp.json() - resp.close() - - return WebhookResponse(message=data["message"], message_code=data["code"]) - - async def unload(self, profile: ProfileConfig) -> None: - """Unload the component for a specific user.""" - config_entries = get_config_entries_for_user_id(self._hass, profile.user_id) - - for config_entry in config_entries: - await config_entry.async_unload(self._hass) - - await self._hass.async_block_till_done() - - assert not get_data_manager_by_user_id(self._hass, profile.user_id) - - -def get_config_entries_for_user_id( - hass: HomeAssistant, user_id: int -) -> tuple[ConfigEntry]: - """Get a list of config entries that apply to a specific withings user.""" - return tuple( - config_entry - for config_entry in hass.config_entries.async_entries(const.DOMAIN) - if config_entry.data.get("token", {}).get("userid") == user_id - ) - - -def get_data_manager_by_user_id( - hass: HomeAssistant, user_id: int -) -> DataManager | None: - """Get a data manager by the user id.""" - return next( - iter( - [ - data_manager - for data_manager in get_all_data_managers(hass) - if data_manager.user_id == user_id - ] - ), - None, - ) - - -async def async_get_entity_id( - hass: HomeAssistant, - description: WithingsEntityDescription, - user_id: int, - platform: str, -) -> 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) - - 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 887a9b8179b..3fc2a3c6461 100644 --- a/tests/components/withings/conftest.py +++ b/tests/components/withings/conftest.py @@ -1,27 +1,169 @@ """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(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 -def component_factory( - hass: HomeAssistant, - hass_client_no_auth, - aioclient_mock: AiohttpClientMocker, - current_request_with_host: None, -): - """Return a factory for initializing the withings component.""" +def webhook_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, + }, + ) + + +@pytest.fixture +def cloudhook_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, + "cloudhook_url": "https://hooks.nabu.casa/ABCD", + }, + ) + + +@pytest.fixture +def polling_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, + }, + ) + + +@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" - ) as api_class_mock: - yield ComponentFactory( - hass, api_class_mock, hass_client_no_auth, aioclient_mock - ) + "homeassistant.components.withings.ConfigEntryWithingsApi", + return_value=mock, + ): + yield mock + + +@pytest.fixture(name="disable_webhook_delay", autouse=True) +def disable_webhook_delay(): + """Disable webhook delay.""" + + mock = AsyncMock() + with patch( + "homeassistant.components.withings.coordinator.SUBSCRIBE_DELAY", + timedelta(seconds=0), + ), patch( + "homeassistant.components.withings.coordinator.UNSUBSCRIBE_DELAY", + timedelta(seconds=0), + ): + yield mock diff --git a/tests/components/withings/fixtures/empty_notify_list.json b/tests/components/withings/fixtures/empty_notify_list.json new file mode 100644 index 00000000000..c905c95e4cb --- /dev/null +++ b/tests/components/withings/fixtures/empty_notify_list.json @@ -0,0 +1,3 @@ +{ + "profiles": [] +} diff --git a/tests/components/withings/fixtures/get_device.json b/tests/components/withings/fixtures/get_device.json new file mode 100644 index 00000000000..64bac3d4a19 --- /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 00000000000..a7a2c09156c --- /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 00000000000..fdc0e064709 --- /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 00000000000..5b368a5c979 --- /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": "https://example.local:8123/api/webhook/55a7335ea8dee830eed4ef8f84cda8f6d80b83af0847dc74032e86120bffed5e", + "expires": 2147483647, + "comment": null + }, + { + "appli": 51, + "callbackurl": "https://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 00000000000..6aa9e5b3784 --- /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 03d72c45296..d258986bdaf 100644 --- a/tests/components/withings/test_binary_sensor.py +++ b/tests/components/withings/test_binary_sensor.py @@ -1,76 +1,75 @@ """Tests for the Withings component.""" +from unittest.mock import AsyncMock + +from aiohttp.client_exceptions import ClientResponseError +import pytest 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.const import STATE_OFF, STATE_ON, STATE_UNKNOWN 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, 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, + webhook_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 setup_integration(hass, webhook_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 + entity_id = "binary_sensor.henk_in_bed" + + assert hass.states.get(entity_id).state == STATE_UNKNOWN + + resp = await call_webhook( + hass, + WEBHOOK_ID, + {"userid": USER_ID, "appli": NotifyAppli.BED_IN}, + client, ) - assert not await async_get_entity_id( - hass, in_bed_attribute, person1.user_id, BINARY_SENSOR_DOMAIN - ) - - # person 0 - await component_factory.setup_profile(person0.user_id) - await component_factory.setup_profile(person1.user_id) - - entity_id0 = await async_get_entity_id( - hass, in_bed_attribute, person0.user_id, BINARY_SENSOR_DOMAIN - ) - 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) + 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_id0).state == STATE_OFF + assert hass.states.get(entity_id).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) - assert resp.message_code == 0 - await hass.async_block_till_done() - assert hass.states.get(entity_id1).state == STATE_ON +async def test_polling_binary_sensor( + hass: HomeAssistant, + withings: AsyncMock, + polling_config_entry: MockConfigEntry, + hass_client_no_auth: ClientSessionGenerator, +) -> None: + """Test binary sensor.""" + await setup_integration(hass, polling_config_entry, False) - # Unload - await component_factory.unload(person0) - await component_factory.unload(person1) + client = await hass_client_no_auth() + + entity_id = "binary_sensor.henk_in_bed" + + assert hass.states.get(entity_id).state == STATE_UNKNOWN + + with pytest.raises(ClientResponseError): + await call_webhook( + hass, + WEBHOOK_ID, + {"userid": USER_ID, "appli": NotifyAppli.BED_IN}, + client, + ) diff --git a/tests/components/withings/test_common.py b/tests/components/withings/test_common.py deleted file mode 100644 index 91915a47920..00000000000 --- 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 c8f3b4bbb29..36edffcc346 100644 --- a/tests/components/withings/test_config_flow.py +++ b/tests/components/withings/test_config_flow.py @@ -1,90 +1,30 @@ """Tests for config flow.""" -from http import HTTPStatus +from unittest.mock import AsyncMock, patch -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 homeassistant.components.withings.const import 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" - ) - config_entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - const.DOMAIN, context={"source": "profile"}, data={const.PROFILE: "person0"} - ) - - assert result - assert result["errors"]["base"] == "already_configured" - - -async def test_config_reauth_profile( +async def test_full_flow( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, - aioclient_mock: AiohttpClientMocker, current_request_with_host: None, + aioclient_mock: AiohttpClientMocker, ) -> 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/", - }, - const.DOMAIN: { - CONF_CLIENT_ID: "my_client_id", - CONF_CLIENT_SECRET: "my_client_secret", - const.CONF_USE_WEBHOOK: False, - }, - } - 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() - - config_entry = MockConfigEntry( - domain=const.DOMAIN, data={const.PROFILE: "person0"}, unique_id="0" - ) - config_entry.add_to_hass(hass) - + """Check full flow.""" result = await hass.config_entries.flow.async_init( - const.DOMAIN, - context={ - "source": config_entries.SOURCE_REAUTH, - "entry_id": config_entry.entry_id, - "title_placeholders": {"name": config_entry.title}, - "unique_id": config_entry.unique_id, - }, - data={"profile": "person0"}, + DOMAIN, context={"source": SOURCE_USER} ) - 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"], - {}, - ) - state = config_entry_oauth2_flow._encode_jwt( hass, { @@ -93,9 +33,18 @@ async def test_config_reauth_profile( }, ) - 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["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() @@ -107,16 +56,200 @@ async def test_config_reauth_profile( "access_token": "mock-access-token", "type": "Bearer", "expires_in": 60, - "userid": "0", + "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, + polling_config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test setup a non-unique profile.""" + await setup_integration(hass, polling_config_entry) + 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", + }, + ) + + 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, + polling_config_entry: MockConfigEntry, + withings: AsyncMock, + current_request_with_host, +) -> None: + """Test reauth an existing profile reauthenticates the config entry.""" + await setup_integration(hass, polling_config_entry) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "entry_id": polling_config_entry.entry_id, + }, + data=polling_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", + }, + ) + 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 - assert result["type"] == "abort" - assert result["reason"] == "already_configured" + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" - entries = hass.config_entries.async_entries(const.DOMAIN) - assert entries - assert entries[0].data["token"]["refresh_token"] == "mock-refresh-token" + +async def test_config_reauth_wrong_account( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + polling_config_entry: MockConfigEntry, + withings: AsyncMock, + current_request_with_host, +) -> None: + """Test reauth with wrong account.""" + await setup_integration(hass, polling_config_entry) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "entry_id": polling_config_entry.entry_id, + }, + data=polling_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", + }, + ) + 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": 12346, + }, + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "wrong_account" diff --git a/tests/components/withings/test_init.py b/tests/components/withings/test_init.py index 9ccc53d0b88..a3918a6ff19 100644 --- a/tests/components/withings/test_init.py +++ b/tests/components/withings/test_init.py @@ -1,33 +1,45 @@ """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, patch +from urllib.parse import urlparse +from freezegun.api import FrozenDateTimeFactory import pytest import voluptuous as vol -from withings_api.common import UnauthorizedException +from withings_api import NotifyListResponse +from withings_api.common import AuthFailedException, NotifyAppli, UnauthorizedException -import homeassistant.components.webhook as webhook -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 import config_entries +from homeassistant.components.cloud import CloudNotAvailable +from homeassistant.components.webhook import async_generate_url +from homeassistant.components.withings import CONFIG_SCHEMA, async_setup +from homeassistant.components.withings.const import CONF_USE_WEBHOOK, DOMAIN from homeassistant.const import ( CONF_CLIENT_ID, CONF_CLIENT_SECRET, - CONF_EXTERNAL_URL, - CONF_UNIT_SYSTEM, - CONF_UNIT_SYSTEM_METRIC, + CONF_WEBHOOK_ID, + EVENT_HOMEASSISTANT_STARTED, ) -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.core import CoreState, HomeAssistant +from homeassistant.util import dt as dt_util -from .common import ComponentFactory, get_data_manager_by_user_id, new_profile_config +from . import call_webhook, setup_integration +from .conftest import USER_ID, WEBHOOK_ID -from tests.common import MockConfigEntry +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + async_mock_cloud_connection_status, + load_json_object_fixture, +) +from tests.components.cloud import mock_cloud +from tests.typing import ClientSessionGenerator def config_schema_validate(withings_config) -> dict: """Assert a schema config succeeds.""" - hass_config = {const.DOMAIN: withings_config} + hass_config = {DOMAIN: withings_config} return CONFIG_SCHEMA(hass_config) @@ -44,7 +56,7 @@ def test_config_schema_basic_config() -> None: { CONF_CLIENT_ID: "my_client_id", CONF_CLIENT_SECRET: "my_client_secret", - const.CONF_USE_WEBHOOK: True, + CONF_USE_WEBHOOK: True, } ) @@ -76,23 +88,23 @@ def test_config_schema_use_webhook() -> None: { CONF_CLIENT_ID: "my_client_id", CONF_CLIENT_SECRET: "my_client_secret", - const.CONF_USE_WEBHOOK: True, + CONF_USE_WEBHOOK: True, } ) - assert config[const.DOMAIN][const.CONF_USE_WEBHOOK] is True + assert config[DOMAIN][CONF_USE_WEBHOOK] is True config = config_schema_validate( { CONF_CLIENT_ID: "my_client_id", CONF_CLIENT_SECRET: "my_client_secret", - const.CONF_USE_WEBHOOK: False, + CONF_USE_WEBHOOK: False, } ) - assert config[const.DOMAIN][const.CONF_USE_WEBHOOK] is False + assert config[DOMAIN][CONF_USE_WEBHOOK] is False config_schema_assert_fail( { CONF_CLIENT_ID: "my_client_id", CONF_CLIENT_SECRET: "my_client_secret", - const.CONF_USE_WEBHOOK: "A", + CONF_USE_WEBHOOK: "A", } ) @@ -106,121 +118,436 @@ 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, + webhook_config_entry: MockConfigEntry, + hass_client_no_auth: ClientSessionGenerator, +) -> None: + """Test data manager webhook subscriptions.""" + await setup_integration(hass, webhook_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 = "https://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) + + +async def test_webhook_subscription_polling_config( + hass: HomeAssistant, + withings: AsyncMock, + polling_config_entry: MockConfigEntry, + hass_client_no_auth: ClientSessionGenerator, + freezer: FrozenDateTimeFactory, +) -> None: + """Test webhook subscriptions not run when polling.""" + await setup_integration(hass, polling_config_entry) + await hass_client_no_auth() + await hass.async_block_till_done() + freezer.tick(timedelta(seconds=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert withings.notify_revoke.call_count == 0 + assert withings.notify_subscribe.call_count == 0 + assert withings.notify_list.call_count == 0 + + @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, + webhook_config_entry: MockConfigEntry, + hass_client_no_auth: ClientSessionGenerator, + method: str, ) -> 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, webhook_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() +async def test_webhooks_request_data( + hass: HomeAssistant, + withings: AsyncMock, + webhook_config_entry: MockConfigEntry, + hass_client_no_auth: ClientSessionGenerator, +) -> None: + """Test calling a webhook requests data.""" + await setup_integration(hass, webhook_config_entry) + client = await hass_client_no_auth() + + assert withings.async_measure_get_meas.call_count == 1 + + await call_webhook( + hass, + WEBHOOK_ID, + {"userid": USER_ID, "appli": NotifyAppli.WEIGHT}, + client, + ) + assert withings.async_measure_get_meas.call_count == 2 + + +async def test_delayed_startup( + hass: HomeAssistant, + withings: AsyncMock, + webhook_config_entry: MockConfigEntry, + hass_client_no_auth: ClientSessionGenerator, + freezer: FrozenDateTimeFactory, +) -> None: + """Test delayed start up.""" + hass.state = CoreState.not_running + await setup_integration(hass, webhook_config_entry) + + withings.async_notify_subscribe.assert_not_called() + client = await hass_client_no_auth() + + assert withings.async_measure_get_meas.call_count == 1 + await hass.async_block_till_done() + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + await call_webhook( + hass, + WEBHOOK_ID, + {"userid": USER_ID, "appli": NotifyAppli.WEIGHT}, + client, + ) + assert withings.async_measure_get_meas.call_count == 2 + + +@pytest.mark.parametrize( + "error", + [ + UnauthorizedException(401), + AuthFailedException(500), + ], +) +async def test_triggering_reauth( + hass: HomeAssistant, + withings: AsyncMock, + polling_config_entry: MockConfigEntry, + error: Exception, +) -> None: + """Test triggering reauth.""" + await setup_integration(hass, polling_config_entry, False) + + withings.async_measure_get_meas.side_effect = error + future = dt_util.utcnow() + timedelta(minutes=10) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() flows = hass.config_entries.flow.async_progress() - assert flows + assert len(flows) == 1 - flow = flows[0] - assert flow["handler"] == const.DOMAIN - - 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" - - await component_factory.unload(person0) + assert flow["step_id"] == "reauth_confirm" + assert flow["handler"] == DOMAIN + assert flow["context"]["source"] == config_entries.SOURCE_REAUTH -async def test_set_config_unique_id( - hass: HomeAssistant, component_factory: ComponentFactory +@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 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, - }, - ) - - 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", - }, - ) + """Test config flow upgrade.""" config_entry.add_to_hass(hass) - 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, - }, - } + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + entry = hass.config_entries.async_get_entry(config_entry.entry_id) + + assert entry.unique_id == "123" + assert entry.data["token"]["userid"] == 123 + assert CONF_WEBHOOK_ID in entry.data + + +async def test_setup_with_cloudhook( + hass: HomeAssistant, cloudhook_config_entry: MockConfigEntry, withings: AsyncMock +) -> None: + """Test if set up with active cloud subscription and cloud hook.""" + + await mock_cloud(hass) + await hass.async_block_till_done() with patch( - "homeassistant.components.withings.common.ConfigEntryWithingsApi", - spec=ConfigEntryWithingsApi, + "homeassistant.components.cloud.async_is_logged_in", return_value=True + ), patch( + "homeassistant.components.cloud.async_is_connected", return_value=True + ), patch( + "homeassistant.components.cloud.async_active_subscription", return_value=True + ), patch( + "homeassistant.components.cloud.async_create_cloudhook", + return_value="https://hooks.nabu.casa/ABCD", + ) as fake_create_cloudhook, patch( + "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", + ), patch( + "homeassistant.components.cloud.async_delete_cloudhook" + ) as fake_delete_cloudhook, patch( + "homeassistant.components.withings.webhook_generate_url" ): - 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 setup_integration(hass, cloudhook_config_entry) + assert hass.components.cloud.async_active_subscription() is True + + assert ( + hass.config_entries.async_entries(DOMAIN)[0].data["cloudhook_url"] + == "https://hooks.nabu.casa/ABCD" + ) + + await hass.async_block_till_done() + assert hass.config_entries.async_entries(DOMAIN) + fake_create_cloudhook.assert_not_called() + + for config_entry in hass.config_entries.async_entries(DOMAIN): + await hass.config_entries.async_remove(config_entry.entry_id) + fake_delete_cloudhook.assert_called_once() + + await hass.async_block_till_done() + assert not hass.config_entries.async_entries(DOMAIN) + + +async def test_removing_entry_with_cloud_unavailable( + hass: HomeAssistant, cloudhook_config_entry: MockConfigEntry, withings: AsyncMock +) -> None: + """Test handling cloud unavailable when deleting entry.""" + + await mock_cloud(hass) + await hass.async_block_till_done() + + with patch( + "homeassistant.components.cloud.async_is_logged_in", return_value=True + ), patch( + "homeassistant.components.cloud.async_is_connected", return_value=True + ), patch( + "homeassistant.components.cloud.async_active_subscription", return_value=True + ), patch( + "homeassistant.components.cloud.async_create_cloudhook", + return_value="https://hooks.nabu.casa/ABCD", + ), patch( + "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", + ), patch( + "homeassistant.components.cloud.async_delete_cloudhook", + side_effect=CloudNotAvailable(), + ), patch( + "homeassistant.components.withings.webhook_generate_url" + ): + await setup_integration(hass, cloudhook_config_entry) + assert hass.components.cloud.async_active_subscription() is True + + await hass.async_block_till_done() + assert hass.config_entries.async_entries(DOMAIN) + + for config_entry in hass.config_entries.async_entries(DOMAIN): + await hass.config_entries.async_remove(config_entry.entry_id) + + await hass.async_block_till_done() + assert not hass.config_entries.async_entries(DOMAIN) + + +async def test_setup_with_cloud( + hass: HomeAssistant, webhook_config_entry: MockConfigEntry, withings: AsyncMock +) -> None: + """Test if set up with active cloud subscription.""" + await mock_cloud(hass) + await hass.async_block_till_done() + + with patch( + "homeassistant.components.cloud.async_is_logged_in", return_value=True + ), patch( + "homeassistant.components.cloud.async_is_connected", return_value=True + ), patch( + "homeassistant.components.cloud.async_active_subscription", return_value=True + ), patch( + "homeassistant.components.cloud.async_create_cloudhook", + return_value="https://hooks.nabu.casa/ABCD", + ) as fake_create_cloudhook, patch( + "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", + ), patch( + "homeassistant.components.cloud.async_delete_cloudhook" + ) as fake_delete_cloudhook, patch( + "homeassistant.components.withings.webhook_generate_url" + ): + await setup_integration(hass, webhook_config_entry) + assert hass.components.cloud.async_active_subscription() is True + assert hass.components.cloud.async_is_connected() is True + fake_create_cloudhook.assert_called_once() + + assert ( + hass.config_entries.async_entries("withings")[0].data["cloudhook_url"] + == "https://hooks.nabu.casa/ABCD" + ) + + await hass.async_block_till_done() + assert hass.config_entries.async_entries(DOMAIN) + + for config_entry in hass.config_entries.async_entries("withings"): + await hass.config_entries.async_remove(config_entry.entry_id) + fake_delete_cloudhook.assert_called_once() + + await hass.async_block_till_done() + assert not hass.config_entries.async_entries(DOMAIN) + + +async def test_setup_without_https( + hass: HomeAssistant, + webhook_config_entry: MockConfigEntry, + withings: AsyncMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test if set up with cloud link and without https.""" + hass.config.components.add("cloud") + with patch( + "homeassistant.helpers.network.get_url", + return_value="http://example.nabu.casa", + ), patch( + "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", + ), patch( + "homeassistant.components.withings.webhook_generate_url" + ) as mock_async_generate_url: + mock_async_generate_url.return_value = "http://example.com" + await setup_integration(hass, webhook_config_entry) + + await hass.async_block_till_done() + mock_async_generate_url.assert_called_once() + + assert "https and port 443 is required to register the webhook" in caplog.text + + +async def test_cloud_disconnect( + hass: HomeAssistant, + withings: AsyncMock, + webhook_config_entry: MockConfigEntry, + hass_client_no_auth: ClientSessionGenerator, + freezer: FrozenDateTimeFactory, +) -> None: + """Test disconnecting from the cloud.""" + await mock_cloud(hass) + await hass.async_block_till_done() + + with patch( + "homeassistant.components.cloud.async_is_logged_in", return_value=True + ), patch( + "homeassistant.components.cloud.async_is_connected", return_value=True + ), patch( + "homeassistant.components.cloud.async_active_subscription", return_value=True + ), patch( + "homeassistant.components.cloud.async_create_cloudhook", + return_value="https://hooks.nabu.casa/ABCD", + ), patch( + "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", + ), patch( + "homeassistant.components.cloud.async_delete_cloudhook" + ), patch( + "homeassistant.components.withings.webhook_generate_url" + ): + await setup_integration(hass, webhook_config_entry) + assert hass.components.cloud.async_active_subscription() is True + assert hass.components.cloud.async_is_connected() is True + await hass.async_block_till_done() - assert config_entry.unique_id == "1234" + withings.async_notify_list.return_value = NotifyListResponse( + **load_json_object_fixture("withings/empty_notify_list.json") + ) + + assert withings.async_notify_subscribe.call_count == 6 + + async_mock_cloud_connection_status(hass, False) + await hass.async_block_till_done() + + assert withings.async_notify_revoke.call_count == 3 + + async_mock_cloud_connection_status(hass, True) + await hass.async_block_till_done() + + assert withings.async_notify_subscribe.call_count == 12 + + +@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, + webhook_config_entry: MockConfigEntry, + hass_client_no_auth: ClientSessionGenerator, + body: dict[str, Any], + expected_code: int, + current_request_with_host: None, +) -> None: + """Test webhook callback.""" + await setup_integration(hass, webhook_config_entry) + client = await hass_client_no_auth() + webhook_url = async_generate_url(hass, WEBHOOK_ID) + + resp = await client.post(urlparse(webhook_url).path, data=body) + + # Wait for remaining tasks to complete. + await hass.async_block_till_done() + + data = await resp.json() + resp.close() + + assert data["code"] == expected_code diff --git a/tests/components/withings/test_sensor.py b/tests/components/withings/test_sensor.py index 6c4bb867f75..fe640e315a0 100644 --- a/tests/components/withings/test_sensor.py +++ b/tests/components/withings/test_sensor.py @@ -1,294 +1,80 @@ """Tests for the Withings component.""" +from datetime import timedelta 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, -) +from freezegun.api import FrozenDateTimeFactory +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.const import DOMAIN, Measurement +from homeassistant.components.withings.entity import WithingsEntityDescription from homeassistant.components.withings.sensor import SENSORS +from homeassistant.const import STATE_UNAVAILABLE 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 .conftest import USER_ID, WEBHOOK_ID + +from tests.common import MockConfigEntry, async_fire_time_changed +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), ) +async def async_get_entity_id( + hass: HomeAssistant, + description: WithingsEntityDescription, + user_id: int, + platform: str, +) -> str | None: + """Get an entity id for a user's attribute.""" + entity_registry = er.async_get(hass) + unique_id = f"withings_{user_id}_{description.measurement.value}" + + return entity_registry.async_get_entity_id(platform, DOMAIN, unique_id) + + def async_assert_state_equals( entity_id: str, state_obj: State, @@ -304,101 +90,76 @@ 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, + webhook_config_entry: MockConfigEntry, + hass_client_no_auth: ClientSessionGenerator, ) -> None: """Test entities enabled by default.""" + await setup_integration(hass, webhook_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 call_webhook( + hass, + WEBHOOK_ID, + {"userid": USER_ID, "appli": NotifyAppli.WEIGHT}, + client, + ) 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: + 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, + polling_config_entry: MockConfigEntry, ) -> None: """Test all entities.""" - entity_registry: EntityRegistry = er.async_get(hass) + await setup_integration(hass, polling_config_entry) - with patch( - "homeassistant.components.withings.sensor.BaseWithingsSensor.entity_registry_enabled_default" - ) as enabled_by_default_mock: - enabled_by_default_mock.return_value = True + for sensor in SENSORS: + entity_id = await async_get_entity_id(hass, sensor, USER_ID, SENSOR_DOMAIN) + assert hass.states.get(entity_id) == snapshot - 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 - ) +async def test_update_failed( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + withings: AsyncMock, + polling_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test all entities.""" + await setup_integration(hass, polling_config_entry, False) - # person 0 - await component_factory.setup_profile(PERSON0.user_id) + withings.async_measure_get_meas.side_effect = Exception + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() - # 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) - - # Unload - await component_factory.unload(PERSON0) + state = hass.states.get("sensor.henk_weight") + assert state is not None + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/wiz/snapshots/test_diagnostics.ambr b/tests/components/wiz/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..5fe9aa883a1 --- /dev/null +++ b/tests/components/wiz/snapshots/test_diagnostics.ambr @@ -0,0 +1,16 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'data': dict({ + 'homeId': '**REDACTED**', + 'mocked': 'mocked', + 'roomId': '**REDACTED**', + }), + 'entry': dict({ + 'data': dict({ + 'host': '1.1.1.1', + }), + 'title': 'Mock Title', + }), + }) +# --- diff --git a/tests/components/wiz/test_diagnostics.py b/tests/components/wiz/test_diagnostics.py index 3bc95cf57ff..ef26e63069b 100644 --- a/tests/components/wiz/test_diagnostics.py +++ b/tests/components/wiz/test_diagnostics.py @@ -1,4 +1,6 @@ """Test WiZ diagnostics.""" +from syrupy import SnapshotAssertion + from homeassistant.core import HomeAssistant from . import async_setup_integration @@ -8,17 +10,11 @@ from tests.typing import ClientSessionGenerator 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_setup_integration(hass) - diag = await get_diagnostics_for_config_entry(hass, hass_client, entry) - assert diag == { - "data": { - "homeId": "**REDACTED**", - "mocked": "mocked", - "roomId": "**REDACTED**", - }, - "entry": {"data": {"host": "1.1.1.1"}, "title": "Mock Title"}, - } + assert await get_diagnostics_for_config_entry(hass, hass_client, entry) == snapshot diff --git a/tests/components/wled/snapshots/test_select.ambr b/tests/components/wled/snapshots/test_select.ambr index 05d61fc18cb..9cfc6c6e3fe 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 f89bde6ee17..1434d2b2b2d 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 9f99bd58615..de01510adb3 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 678b4a44459..ab8330293ba 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/workday/__init__.py b/tests/components/workday/__init__.py index f87328998e1..f9e44359b00 100644 --- a/tests/components/workday/__init__.py +++ b/tests/components/workday/__init__.py @@ -40,6 +40,22 @@ async def init_integration( return config_entry +TEST_CONFIG_NO_COUNTRY = { + "name": DEFAULT_NAME, + "excludes": DEFAULT_EXCLUDES, + "days_offset": DEFAULT_OFFSET, + "workdays": DEFAULT_WORKDAYS, + "add_holidays": [], + "remove_holidays": [], +} +TEST_CONFIG_NO_COUNTRY_ADD_HOLIDAY = { + "name": DEFAULT_NAME, + "excludes": DEFAULT_EXCLUDES, + "days_offset": DEFAULT_OFFSET, + "workdays": DEFAULT_WORKDAYS, + "add_holidays": ["2020-02-24"], + "remove_holidays": [], +} TEST_CONFIG_WITH_PROVINCE = { "name": DEFAULT_NAME, "country": "DE", @@ -181,3 +197,53 @@ TEST_CONFIG_INCORRECT_ADD_REMOVE = { "add_holidays": ["2023-12-32"], "remove_holidays": ["2023-12-32"], } +TEST_CONFIG_INCORRECT_ADD_DATE_RANGE = { + "name": DEFAULT_NAME, + "country": "DE", + "province": "BW", + "excludes": DEFAULT_EXCLUDES, + "days_offset": DEFAULT_OFFSET, + "workdays": DEFAULT_WORKDAYS, + "add_holidays": ["2023-12-01", "2023-12-30,2023-12-32"], + "remove_holidays": [], +} +TEST_CONFIG_INCORRECT_REMOVE_DATE_RANGE = { + "name": DEFAULT_NAME, + "country": "DE", + "province": "BW", + "excludes": DEFAULT_EXCLUDES, + "days_offset": DEFAULT_OFFSET, + "workdays": DEFAULT_WORKDAYS, + "add_holidays": [], + "remove_holidays": ["2023-12-25", "2023-12-30,2023-12-32"], +} +TEST_CONFIG_INCORRECT_ADD_DATE_RANGE_LEN = { + "name": DEFAULT_NAME, + "country": "DE", + "province": "BW", + "excludes": DEFAULT_EXCLUDES, + "days_offset": DEFAULT_OFFSET, + "workdays": DEFAULT_WORKDAYS, + "add_holidays": ["2023-12-01", "2023-12-29,2023-12-30,2023-12-31"], + "remove_holidays": [], +} +TEST_CONFIG_INCORRECT_REMOVE_DATE_RANGE_LEN = { + "name": DEFAULT_NAME, + "country": "DE", + "province": "BW", + "excludes": DEFAULT_EXCLUDES, + "days_offset": DEFAULT_OFFSET, + "workdays": DEFAULT_WORKDAYS, + "add_holidays": [], + "remove_holidays": ["2023-12-25", "2023-12-29,2023-12-30,2023-12-31"], +} +TEST_CONFIG_ADD_REMOVE_DATE_RANGE = { + "name": DEFAULT_NAME, + "country": "DE", + "province": "BW", + "excludes": DEFAULT_EXCLUDES, + "days_offset": DEFAULT_OFFSET, + "workdays": DEFAULT_WORKDAYS, + "add_holidays": ["2022-12-01", "2022-12-05,2022-12-15"], + "remove_holidays": ["2022-12-04", "2022-12-24,2022-12-26"], +} diff --git a/tests/components/workday/test_binary_sensor.py b/tests/components/workday/test_binary_sensor.py index 51280c8d75c..5c387e9a179 100644 --- a/tests/components/workday/test_binary_sensor.py +++ b/tests/components/workday/test_binary_sensor.py @@ -12,13 +12,20 @@ from homeassistant.setup import async_setup_component from homeassistant.util.dt import UTC from . import ( + TEST_CONFIG_ADD_REMOVE_DATE_RANGE, TEST_CONFIG_DAY_AFTER_TOMORROW, TEST_CONFIG_EXAMPLE_1, TEST_CONFIG_EXAMPLE_2, TEST_CONFIG_INCLUDE_HOLIDAY, + TEST_CONFIG_INCORRECT_ADD_DATE_RANGE, + TEST_CONFIG_INCORRECT_ADD_DATE_RANGE_LEN, TEST_CONFIG_INCORRECT_ADD_REMOVE, TEST_CONFIG_INCORRECT_COUNTRY, TEST_CONFIG_INCORRECT_PROVINCE, + TEST_CONFIG_INCORRECT_REMOVE_DATE_RANGE, + TEST_CONFIG_INCORRECT_REMOVE_DATE_RANGE_LEN, + TEST_CONFIG_NO_COUNTRY, + TEST_CONFIG_NO_COUNTRY_ADD_HOLIDAY, TEST_CONFIG_NO_PROVINCE, TEST_CONFIG_NO_STATE, TEST_CONFIG_REMOVE_HOLIDAY, @@ -49,6 +56,7 @@ async def test_valid_country_yaml() -> None: @pytest.mark.parametrize( ("config", "expected_state"), [ + (TEST_CONFIG_NO_COUNTRY, "on"), (TEST_CONFIG_WITH_PROVINCE, "off"), (TEST_CONFIG_NO_PROVINCE, "off"), (TEST_CONFIG_WITH_STATE, "on"), @@ -71,6 +79,7 @@ async def test_setup( await init_integration(hass, config) state = hass.states.get("binary_sensor.workday_sensor") + assert state is not None assert state.state == expected_state assert state.attributes == { "friendly_name": "Workday Sensor", @@ -99,6 +108,7 @@ async def test_setup_from_import( await hass.async_block_till_done() state = hass.states.get("binary_sensor.workday_sensor") + assert state is not None assert state.state == "off" assert state.attributes == { "friendly_name": "Workday Sensor", @@ -110,7 +120,6 @@ async def test_setup_from_import( async def test_setup_with_invalid_province_from_yaml(hass: HomeAssistant) -> None: """Test setup invalid province with import.""" - await async_setup_component( hass, "binary_sensor", @@ -137,11 +146,20 @@ async def test_setup_with_working_holiday( await init_integration(hass, TEST_CONFIG_INCLUDE_HOLIDAY) state = hass.states.get("binary_sensor.workday_sensor") + assert state is not None assert state.state == "on" +@pytest.mark.parametrize( + "config", + [ + TEST_CONFIG_EXAMPLE_2, + TEST_CONFIG_NO_COUNTRY_ADD_HOLIDAY, + ], +) async def test_setup_add_holiday( hass: HomeAssistant, + config: dict[str, Any], freezer: FrozenDateTimeFactory, ) -> None: """Test setup from various configs.""" @@ -149,6 +167,20 @@ async def test_setup_add_holiday( await init_integration(hass, TEST_CONFIG_EXAMPLE_2) state = hass.states.get("binary_sensor.workday_sensor") + assert state is not None + assert state.state == "off" + + +async def test_setup_no_country_weekend( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test setup shows weekend as non-workday with no country.""" + freezer.move_to(datetime(2020, 2, 23, 12, tzinfo=UTC)) # Sunday + await init_integration(hass, TEST_CONFIG_NO_COUNTRY) + + state = hass.states.get("binary_sensor.workday_sensor") + assert state is not None assert state.state == "off" @@ -161,6 +193,7 @@ async def test_setup_remove_holiday( await init_integration(hass, TEST_CONFIG_REMOVE_HOLIDAY) state = hass.states.get("binary_sensor.workday_sensor") + assert state is not None assert state.state == "on" @@ -173,6 +206,7 @@ async def test_setup_remove_holiday_named( await init_integration(hass, TEST_CONFIG_REMOVE_NAMED) state = hass.states.get("binary_sensor.workday_sensor") + assert state is not None assert state.state == "on" @@ -185,6 +219,7 @@ async def test_setup_day_after_tomorrow( await init_integration(hass, TEST_CONFIG_DAY_AFTER_TOMORROW) state = hass.states.get("binary_sensor.workday_sensor") + assert state is not None assert state.state == "off" @@ -234,3 +269,53 @@ async def test_setup_incorrect_add_remove( in caplog.text ) assert "No holiday found matching '2023-12-32'" in caplog.text + + +async def test_setup_incorrect_add_holiday_ranges( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test setup with incorrect add/remove holiday ranges.""" + freezer.move_to(datetime(2017, 1, 6, 12, tzinfo=UTC)) # Friday + await init_integration(hass, TEST_CONFIG_INCORRECT_ADD_DATE_RANGE) + await init_integration(hass, TEST_CONFIG_INCORRECT_ADD_DATE_RANGE_LEN, "2") + + hass.states.get("binary_sensor.workday_sensor") + + assert "Incorrect dates in date range: 2023-12-30,2023-12-32" in caplog.text + assert ( + "Incorrect dates in date range: 2023-12-29,2023-12-30,2023-12-31" in caplog.text + ) + + +async def test_setup_incorrect_remove_holiday_ranges( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test setup with incorrect add/remove holiday ranges.""" + freezer.move_to(datetime(2017, 1, 6, 12, tzinfo=UTC)) # Friday + await init_integration(hass, TEST_CONFIG_INCORRECT_REMOVE_DATE_RANGE) + await init_integration(hass, TEST_CONFIG_INCORRECT_REMOVE_DATE_RANGE_LEN, "2") + + hass.states.get("binary_sensor.workday_sensor") + + assert "Incorrect dates in date range: 2023-12-30,2023-12-32" in caplog.text + assert ( + "Incorrect dates in date range: 2023-12-29,2023-12-30,2023-12-31" in caplog.text + ) + + +async def test_setup_date_range( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test setup with date range.""" + freezer.move_to( + datetime(2022, 12, 26, 12, tzinfo=UTC) + ) # Boxing Day should be working day + await init_integration(hass, TEST_CONFIG_ADD_REMOVE_DATE_RANGE) + + state = hass.states.get("binary_sensor.workday_sensor") + assert state.state == "on" diff --git a/tests/components/workday/test_config_flow.py b/tests/components/workday/test_config_flow.py index 7e28471c78c..65e6c70fa00 100644 --- a/tests/components/workday/test_config_flow.py +++ b/tests/components/workday/test_config_flow.py @@ -72,6 +72,48 @@ async def test_form(hass: HomeAssistant) -> None: } +async def test_form_no_country(hass: HomeAssistant) -> None: + """Test we get the forms correctly without a country.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_NAME: "Workday Sensor", + CONF_COUNTRY: "none", + }, + ) + await hass.async_block_till_done() + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_EXCLUDES: DEFAULT_EXCLUDES, + CONF_OFFSET: DEFAULT_OFFSET, + CONF_WORKDAYS: DEFAULT_WORKDAYS, + CONF_ADD_HOLIDAYS: [], + CONF_REMOVE_HOLIDAYS: [], + }, + ) + await hass.async_block_till_done() + + assert result3["type"] == FlowResultType.CREATE_ENTRY + assert result3["title"] == "Workday Sensor" + assert result3["options"] == { + "name": "Workday Sensor", + "country": None, + "excludes": ["sat", "sun", "holiday"], + "days_offset": 0, + "workdays": ["mon", "tue", "wed", "thu", "fri"], + "add_holidays": [], + "remove_holidays": [], + "province": None, + } + + async def test_form_no_subdivision(hass: HomeAssistant) -> None: """Test we get the forms correctly without subdivision.""" @@ -486,3 +528,147 @@ async def test_options_form_abort_duplicate(hass: HomeAssistant) -> None: assert result2["type"] == FlowResultType.FORM assert result2["errors"] == {"base": "already_configured"} + + +async def test_form_incorrect_date_range(hass: HomeAssistant) -> None: + """Test errors in setup entry.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_NAME: "Workday Sensor", + CONF_COUNTRY: "DE", + }, + ) + await hass.async_block_till_done() + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_EXCLUDES: DEFAULT_EXCLUDES, + CONF_OFFSET: DEFAULT_OFFSET, + CONF_WORKDAYS: DEFAULT_WORKDAYS, + CONF_ADD_HOLIDAYS: ["2022-12-12", "2022-12-30,2022-12-32"], + CONF_REMOVE_HOLIDAYS: [], + CONF_PROVINCE: "none", + }, + ) + await hass.async_block_till_done() + assert result3["errors"] == {"add_holidays": "add_holiday_range_error"} + + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_EXCLUDES: DEFAULT_EXCLUDES, + CONF_OFFSET: DEFAULT_OFFSET, + CONF_WORKDAYS: DEFAULT_WORKDAYS, + CONF_ADD_HOLIDAYS: ["2022-12-12"], + CONF_REMOVE_HOLIDAYS: ["2022-12-25", "2022-12-30,2022-12-32"], + CONF_PROVINCE: "none", + }, + ) + await hass.async_block_till_done() + + assert result3["errors"] == {"remove_holidays": "remove_holiday_range_error"} + + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_EXCLUDES: DEFAULT_EXCLUDES, + CONF_OFFSET: DEFAULT_OFFSET, + CONF_WORKDAYS: DEFAULT_WORKDAYS, + CONF_ADD_HOLIDAYS: ["2022-12-12", "2022-12-01,2022-12-10"], + CONF_REMOVE_HOLIDAYS: ["2022-12-25", "2022-12-30,2022-12-31"], + CONF_PROVINCE: "none", + }, + ) + await hass.async_block_till_done() + + assert result3["type"] == FlowResultType.CREATE_ENTRY + assert result3["title"] == "Workday Sensor" + assert result3["options"] == { + "name": "Workday Sensor", + "country": "DE", + "excludes": ["sat", "sun", "holiday"], + "days_offset": 0, + "workdays": ["mon", "tue", "wed", "thu", "fri"], + "add_holidays": ["2022-12-12", "2022-12-01,2022-12-10"], + "remove_holidays": ["2022-12-25", "2022-12-30,2022-12-31"], + "province": None, + } + + +async def test_options_form_incorrect_date_ranges(hass: HomeAssistant) -> None: + """Test errors in options.""" + + entry = await init_integration( + hass, + { + "name": "Workday Sensor", + "country": "DE", + "excludes": ["sat", "sun", "holiday"], + "days_offset": 0, + "workdays": ["mon", "tue", "wed", "thu", "fri"], + "add_holidays": [], + "remove_holidays": [], + "province": None, + }, + ) + + result = await hass.config_entries.options.async_init(entry.entry_id) + + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "excludes": ["sat", "sun", "holiday"], + "days_offset": 0, + "workdays": ["mon", "tue", "wed", "thu", "fri"], + "add_holidays": ["2022-12-30,2022-12-32"], + "remove_holidays": [], + "province": "BW", + }, + ) + + assert result2["errors"] == {"add_holidays": "add_holiday_range_error"} + + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "excludes": ["sat", "sun", "holiday"], + "days_offset": 0, + "workdays": ["mon", "tue", "wed", "thu", "fri"], + "add_holidays": ["2022-12-30,2022-12-31"], + "remove_holidays": ["2022-13-25,2022-12-26"], + "province": "BW", + }, + ) + + assert result2["errors"] == {"remove_holidays": "remove_holiday_range_error"} + + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "excludes": ["sat", "sun", "holiday"], + "days_offset": 0, + "workdays": ["mon", "tue", "wed", "thu", "fri"], + "add_holidays": ["2022-12-30,2022-12-31"], + "remove_holidays": ["2022-12-25,2022-12-26"], + "province": "BW", + }, + ) + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["data"] == { + "name": "Workday Sensor", + "country": "DE", + "excludes": ["sat", "sun", "holiday"], + "days_offset": 0, + "workdays": ["mon", "tue", "wed", "thu", "fri"], + "add_holidays": ["2022-12-30,2022-12-31"], + "remove_holidays": ["2022-12-25,2022-12-26"], + "province": "BW", + } diff --git a/tests/components/workday/test_repairs.py b/tests/components/workday/test_repairs.py new file mode 100644 index 00000000000..38b2142dfb7 --- /dev/null +++ b/tests/components/workday/test_repairs.py @@ -0,0 +1,399 @@ +"""Test repairs for unifiprotect.""" +from __future__ import annotations + +from http import HTTPStatus + +from homeassistant.components.repairs.websocket_api import ( + RepairsFlowIndexView, + RepairsFlowResourceView, +) +from homeassistant.components.workday.const import DOMAIN +from homeassistant.const import CONF_COUNTRY +from homeassistant.core import HomeAssistant +from homeassistant.helpers.issue_registry import async_create_issue +from homeassistant.setup import async_setup_component + +from . import ( + TEST_CONFIG_INCORRECT_COUNTRY, + TEST_CONFIG_INCORRECT_PROVINCE, + init_integration, +) + +from tests.common import ANY +from tests.typing import ClientSessionGenerator, WebSocketGenerator + + +async def test_bad_country( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test fixing bad country.""" + assert await async_setup_component(hass, "repairs", {}) + entry = await init_integration(hass, TEST_CONFIG_INCORRECT_COUNTRY) + + state = hass.states.get("binary_sensor.workday_sensor") + assert not state + + ws_client = await hass_ws_client(hass) + client = await hass_client() + + await ws_client.send_json({"id": 1, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + + assert msg["success"] + assert len(msg["result"]["issues"]) > 0 + issue = None + for i in msg["result"]["issues"]: + if i["issue_id"] == "bad_country": + issue = i + assert issue is not None + + url = RepairsFlowIndexView.url + resp = await client.post(url, json={"handler": DOMAIN, "issue_id": "bad_country"}) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data["description_placeholders"] == {"title": entry.title} + assert data["step_id"] == "country" + + url = RepairsFlowResourceView.url.format(flow_id=flow_id) + resp = await client.post(url, json={"country": "DE"}) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + url = RepairsFlowResourceView.url.format(flow_id=flow_id) + resp = await client.post(url, json={"province": "HB"}) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + assert data["type"] == "create_entry" + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.workday_sensor") + assert state + + await ws_client.send_json({"id": 2, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + + assert msg["success"] + issue = None + for i in msg["result"]["issues"]: + if i["issue_id"] == "bad_country": + issue = i + assert not issue + + +async def test_bad_country_none( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test fixing bad country with no province.""" + assert await async_setup_component(hass, "repairs", {}) + entry = await init_integration(hass, TEST_CONFIG_INCORRECT_COUNTRY) + + state = hass.states.get("binary_sensor.workday_sensor") + assert not state + + ws_client = await hass_ws_client(hass) + client = await hass_client() + + await ws_client.send_json({"id": 1, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + + assert msg["success"] + assert len(msg["result"]["issues"]) > 0 + issue = None + for i in msg["result"]["issues"]: + if i["issue_id"] == "bad_country": + issue = i + assert issue is not None + + url = RepairsFlowIndexView.url + resp = await client.post(url, json={"handler": DOMAIN, "issue_id": "bad_country"}) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data["description_placeholders"] == {"title": entry.title} + assert data["step_id"] == "country" + + url = RepairsFlowResourceView.url.format(flow_id=flow_id) + resp = await client.post(url, json={"country": "DE"}) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + url = RepairsFlowResourceView.url.format(flow_id=flow_id) + resp = await client.post(url, json={"province": "none"}) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + assert data["type"] == "create_entry" + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.workday_sensor") + assert state + + await ws_client.send_json({"id": 2, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + + assert msg["success"] + issue = None + for i in msg["result"]["issues"]: + if i["issue_id"] == "bad_country": + issue = i + assert not issue + + +async def test_bad_country_no_province( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test fixing bad country.""" + assert await async_setup_component(hass, "repairs", {}) + entry = await init_integration(hass, TEST_CONFIG_INCORRECT_COUNTRY) + + state = hass.states.get("binary_sensor.workday_sensor") + assert not state + + ws_client = await hass_ws_client(hass) + client = await hass_client() + + await ws_client.send_json({"id": 1, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + + assert msg["success"] + assert len(msg["result"]["issues"]) > 0 + issue = None + for i in msg["result"]["issues"]: + if i["issue_id"] == "bad_country": + issue = i + assert issue is not None + + url = RepairsFlowIndexView.url + resp = await client.post(url, json={"handler": DOMAIN, "issue_id": "bad_country"}) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data["description_placeholders"] == {"title": entry.title} + assert data["step_id"] == "country" + + url = RepairsFlowResourceView.url.format(flow_id=flow_id) + resp = await client.post(url, json={"country": "SE"}) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + assert data["type"] == "create_entry" + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.workday_sensor") + assert state + + await ws_client.send_json({"id": 2, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + + assert msg["success"] + issue = None + for i in msg["result"]["issues"]: + if i["issue_id"] == "bad_country": + issue = i + assert not issue + + +async def test_bad_province( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test fixing bad province.""" + assert await async_setup_component(hass, "repairs", {}) + entry = await init_integration(hass, TEST_CONFIG_INCORRECT_PROVINCE) + + state = hass.states.get("binary_sensor.workday_sensor") + assert not state + + ws_client = await hass_ws_client(hass) + client = await hass_client() + + await ws_client.send_json({"id": 1, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + + assert msg["success"] + assert len(msg["result"]["issues"]) > 0 + issue = None + for i in msg["result"]["issues"]: + if i["issue_id"] == "bad_province": + issue = i + assert issue is not None + + url = RepairsFlowIndexView.url + resp = await client.post(url, json={"handler": DOMAIN, "issue_id": "bad_province"}) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data["description_placeholders"] == { + CONF_COUNTRY: "DE", + "title": entry.title, + } + assert data["step_id"] == "province" + + url = RepairsFlowResourceView.url.format(flow_id=flow_id) + resp = await client.post(url, json={"province": "BW"}) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + assert data["type"] == "create_entry" + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.workday_sensor") + assert state + + await ws_client.send_json({"id": 2, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + + assert msg["success"] + issue = None + for i in msg["result"]["issues"]: + if i["issue_id"] == "bad_province": + issue = i + assert not issue + + +async def test_bad_province_none( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test fixing bad province selecting none.""" + assert await async_setup_component(hass, "repairs", {}) + entry = await init_integration(hass, TEST_CONFIG_INCORRECT_PROVINCE) + + state = hass.states.get("binary_sensor.workday_sensor") + assert not state + + ws_client = await hass_ws_client(hass) + client = await hass_client() + + await ws_client.send_json({"id": 1, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + + assert msg["success"] + assert len(msg["result"]["issues"]) > 0 + issue = None + for i in msg["result"]["issues"]: + if i["issue_id"] == "bad_province": + issue = i + assert issue is not None + + url = RepairsFlowIndexView.url + resp = await client.post(url, json={"handler": DOMAIN, "issue_id": "bad_province"}) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data["description_placeholders"] == { + CONF_COUNTRY: "DE", + "title": entry.title, + } + assert data["step_id"] == "province" + + url = RepairsFlowResourceView.url.format(flow_id=flow_id) + resp = await client.post(url, json={"province": "none"}) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + assert data["type"] == "create_entry" + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.workday_sensor") + assert state + + await ws_client.send_json({"id": 2, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + + assert msg["success"] + issue = None + for i in msg["result"]["issues"]: + if i["issue_id"] == "bad_province": + issue = i + assert not issue + + +async def test_other_fixable_issues( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test fixing bad province selecting none.""" + assert await async_setup_component(hass, "repairs", {}) + await init_integration(hass, TEST_CONFIG_INCORRECT_PROVINCE) + + ws_client = await hass_ws_client(hass) + client = await hass_client() + + await ws_client.send_json({"id": 1, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + + assert msg["success"] + + issue = { + "breaks_in_ha_version": "2022.9.0dev0", + "domain": DOMAIN, + "issue_id": "issue_1", + "is_fixable": True, + "learn_more_url": "", + "severity": "error", + "translation_key": "issue_1", + } + async_create_issue( + hass, + issue["domain"], + issue["issue_id"], + breaks_in_ha_version=issue["breaks_in_ha_version"], + is_fixable=issue["is_fixable"], + is_persistent=False, + learn_more_url=None, + severity=issue["severity"], + translation_key=issue["translation_key"], + ) + + await ws_client.send_json({"id": 2, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + + assert msg["success"] + results = msg["result"]["issues"] + assert { + "breaks_in_ha_version": "2022.9.0dev0", + "created": ANY, + "dismissed_version": None, + "domain": "workday", + "is_fixable": True, + "issue_domain": None, + "issue_id": "issue_1", + "learn_more_url": None, + "severity": "error", + "translation_key": "issue_1", + "translation_placeholders": None, + "ignored": False, + } in results + + url = RepairsFlowIndexView.url + resp = await client.post(url, json={"handler": DOMAIN, "issue_id": "issue_1"}) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data["step_id"] == "confirm" + + url = RepairsFlowResourceView.url.format(flow_id=flow_id) + resp = await client.post(url) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + assert data["type"] == "create_entry" + await hass.async_block_till_done() diff --git a/tests/components/wyoming/__init__.py b/tests/components/wyoming/__init__.py index c326228ec8b..e04ff4eda03 100644 --- a/tests/components/wyoming/__init__.py +++ b/tests/components/wyoming/__init__.py @@ -92,7 +92,11 @@ class MockAsyncTcpClient: async def read_event(self): """Receive.""" await asyncio.sleep(0) # force context switch - return self.responses.pop(0) + + if self.responses: + return self.responses.pop(0) + + return None async def __aenter__(self): """Enter.""" diff --git a/tests/components/wyoming/snapshots/test_wake_word.ambr b/tests/components/wyoming/snapshots/test_wake_word.ambr index 041112cb6ff..41518634a51 100644 --- a/tests/components/wyoming/snapshots/test_wake_word.ambr +++ b/tests/components/wyoming/snapshots/test_wake_word.ambr @@ -8,6 +8,6 @@ ), ]), 'timestamp': 0, - 'ww_id': 'Test Model', + 'wake_word_id': 'Test Model', }) # --- diff --git a/tests/components/wyoming/test_wake_word.py b/tests/components/wyoming/test_wake_word.py index cd156c660a8..b3c09d4e816 100644 --- a/tests/components/wyoming/test_wake_word.py +++ b/tests/components/wyoming/test_wake_word.py @@ -25,7 +25,7 @@ async def test_support(hass: HomeAssistant, init_wyoming_wake_word) -> None: assert entity is not None assert entity.supported_wake_words == [ - wake_word.WakeWord(ww_id="Test Model", name="Test Model") + wake_word.WakeWord(id="Test Model", name="Test Model") ] @@ -54,7 +54,7 @@ async def test_streaming_audio( "homeassistant.components.wyoming.wake_word.AsyncTcpClient", MockAsyncTcpClient(client_events), ): - result = await entity.async_process_audio_stream(audio_stream()) + result = await entity.async_process_audio_stream(audio_stream(), None) assert result is not None assert result == snapshot @@ -78,7 +78,7 @@ async def test_streaming_audio_connection_lost( "homeassistant.components.wyoming.wake_word.AsyncTcpClient", MockAsyncTcpClient([None]), ): - result = await entity.async_process_audio_stream(audio_stream()) + result = await entity.async_process_audio_stream(audio_stream(), None) assert result is None @@ -103,6 +103,57 @@ async def test_streaming_audio_oserror( "homeassistant.components.wyoming.wake_word.AsyncTcpClient", mock_client, ), patch.object(mock_client, "read_event", side_effect=OSError("Boom!")): - result = await entity.async_process_audio_stream(audio_stream()) + result = await entity.async_process_audio_stream(audio_stream(), None) + + assert result is None + + +async def test_detect_message_with_wake_word( + hass: HomeAssistant, init_wyoming_wake_word +) -> None: + """Test that specifying a wake word id produces a Detect message with that id.""" + entity = wake_word.async_get_wake_word_detection_entity( + hass, "wake_word.test_wake_word" + ) + assert entity is not None + + async def audio_stream(): + yield b"chunk1", 1000 + + mock_client = MockAsyncTcpClient( + [Detection(name="my-wake-word", timestamp=1000).event()] + ) + + with patch( + "homeassistant.components.wyoming.wake_word.AsyncTcpClient", + mock_client, + ): + result = await entity.async_process_audio_stream(audio_stream(), "my-wake-word") + + assert isinstance(result, wake_word.DetectionResult) + assert result.wake_word_id == "my-wake-word" + + +async def test_detect_message_with_wrong_wake_word( + hass: HomeAssistant, init_wyoming_wake_word +) -> None: + """Test that specifying a wake word id filters invalid detections.""" + entity = wake_word.async_get_wake_word_detection_entity( + hass, "wake_word.test_wake_word" + ) + assert entity is not None + + async def audio_stream(): + yield b"chunk1", 1000 + + mock_client = MockAsyncTcpClient( + [Detection(name="not-my-wake-word", timestamp=1000).event()], + ) + + with patch( + "homeassistant.components.wyoming.wake_word.AsyncTcpClient", + mock_client, + ): + result = await entity.async_process_audio_stream(audio_stream(), "my-wake-word") assert result is None diff --git a/tests/components/xiaomi_aqara/test_config_flow.py b/tests/components/xiaomi_aqara/test_config_flow.py index 2f049a86620..d15a442a840 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 848bb7c8d9f..a436908b44f 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 00000000000..47f8cbc509e --- /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 d60ead707fb..c7d279220f8 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 @@ CAPABILITIES = { 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 8f46407aff6..0bd5b5f59d0 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 b07e2d5880a..54406bb1b4d 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 db1da3721ee..44155d741b7 100644 --- a/tests/components/zha/common.py +++ b/tests/components/zha/common.py @@ -9,7 +9,10 @@ import zigpy.zcl 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 7d391872a77..e7dc7316f73 100644 --- a/tests/components/zha/conftest.py +++ b/tests/components/zha/conftest.py @@ -22,9 +22,10 @@ import zigpy.zdo.types as zdo_t 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 zigpy_device_mock(zigpy_app_controller): 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(): @@ -305,7 +306,7 @@ def zha_device_joined(hass, setup_zha): if setup_zha: await setup_zha_fixture() - zha_gateway = common.get_zha_gateway(hass) + 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() @@ -329,7 +330,7 @@ def zha_device_restored(hass, zigpy_app_controller, setup_zha): if setup_zha: await setup_zha_fixture() - zha_gateway = hass.data[zha_const.DATA_ZHA][zha_const.DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) return zha_gateway.get_device(zigpy_dev.ieee) return _zha_device diff --git a/tests/components/zha/test_api.py b/tests/components/zha/test_api.py index c2cb16efcc8..89742fb1e49 100644 --- a/tests/components/zha/test_api.py +++ b/tests/components/zha/test_api.py @@ -11,6 +11,7 @@ import zigpy.state 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 7e0e8eaab85..24162296cd5 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.cluster_handlers as cluster_handlers 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 d50d43da675..9ec8048ea03 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 @@ import serial.tools.list_ports 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 @@ -141,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, @@ -191,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, @@ -246,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, @@ -309,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, @@ -342,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, @@ -364,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, @@ -697,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, @@ -1174,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 31ffe9449e2..229fde89f15 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 491e2d96d4f..096d83567fe 100644 --- a/tests/components/zha/test_device_trigger.py +++ b/tests/components/zha/test_device_trigger.py @@ -477,6 +477,7 @@ async def test_validate_trigger_config_unloaded_bad_info( # 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) diff --git a/tests/components/zha/test_diagnostics.py b/tests/components/zha/test_diagnostics.py index 6bcb321ab14..c13bb36c1c0 100644 --- a/tests/components/zha/test_diagnostics.py +++ b/tests/components/zha/test_diagnostics.py @@ -6,8 +6,8 @@ import zigpy.profiles.zha as zha 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 e0785601b4f..768f974d928 100644 --- a/tests/components/zha/test_discover.py +++ b/tests/components/zha/test_discover.py @@ -20,12 +20,12 @@ import homeassistant.components.zha.core.const as zha_const 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 3d0b065ab18..81ab1c2e0f5 100644 --- a/tests/components/zha/test_fan.py +++ b/tests/components/zha/test_fan.py @@ -21,6 +21,7 @@ from homeassistant.components.fan import ( 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 @@ from .common import ( 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 0f791a08955..214bfcad9f0 100644 --- a/tests/components/zha/test_gateway.py +++ b/tests/components/zha/test_gateway.py @@ -11,11 +11,12 @@ import zigpy.zcl.clusters.lighting as lighting 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_light.py b/tests/components/zha/test_light.py index c1f5cf04e35..da91340b864 100644 --- a/tests/components/zha/test_light.py +++ b/tests/components/zha/test_light.py @@ -20,9 +20,11 @@ from homeassistant.components.zha.core.const import ( 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 @@ from .common import ( 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_silabs_multiprotocol.py b/tests/components/zha/test_silabs_multiprotocol.py index beae0230901..4d11ae81b08 100644 --- a/tests/components/zha/test_silabs_multiprotocol.py +++ b/tests/components/zha/test_silabs_multiprotocol.py @@ -9,7 +9,8 @@ import zigpy.backups 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 fe7450eff67..b07b34763d1 100644 --- a/tests/components/zha/test_switch.py +++ b/tests/components/zha/test_switch.py @@ -19,6 +19,7 @@ import zigpy.zcl.foundation as zcl_f 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 @@ from .common import ( 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 740ffd6c06c..b0e15a01318 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 dcd847a6e12..bbc836488c2 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -483,6 +483,12 @@ def fibaro_fgr222_shutter_state_fixture(): return json.loads(load_fixture("zwave_js/cover_fibaro_fgr222_state.json")) +@pytest.fixture(name="fibaro_fgr223_shutter_state", scope="session") +def fibaro_fgr223_shutter_state_fixture(): + """Load the Fibaro FGR223 node state fixture data.""" + return json.loads(load_fixture("zwave_js/cover_fibaro_fgr223_state.json")) + + @pytest.fixture(name="merten_507801_state", scope="session") def merten_507801_state_fixture(): """Load the Merten 507801 Shutter node state fixture data.""" @@ -650,6 +656,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 @@ -1048,6 +1060,14 @@ def fibaro_fgr222_shutter_cover_fixture(client, fibaro_fgr222_shutter_state): return node +@pytest.fixture(name="fibaro_fgr223_shutter") +def fibaro_fgr223_shutter_cover_fixture(client, fibaro_fgr223_shutter_state): + """Mock a Fibaro FGR223 Shutter node.""" + node = Node(client, copy.deepcopy(fibaro_fgr223_shutter_state)) + client.driver.controller.nodes[node.node_id] = node + return node + + @pytest.fixture(name="merten_507801") def merten_507801_cover_fixture(client, merten_507801_state): """Mock a Merten 507801 Shutter node.""" @@ -1262,3 +1282,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/cover_fibaro_fgr223_state.json b/tests/components/zwave_js/fixtures/cover_fibaro_fgr223_state.json new file mode 100644 index 00000000000..b0f4992e319 --- /dev/null +++ b/tests/components/zwave_js/fixtures/cover_fibaro_fgr223_state.json @@ -0,0 +1,2325 @@ +{ + "nodeId": 10, + "index": 0, + "installerIcon": 6400, + "userIcon": 6400, + "status": 4, + "ready": true, + "isListening": true, + "isRouting": true, + "isSecure": true, + "manufacturerId": 271, + "productId": 4096, + "productType": 771, + "firmwareVersion": "5.1", + "zwavePlusVersion": 1, + "name": "fgr 223 test cover", + "location": "test location", + "deviceConfig": { + "filename": "/data/db/devices/0x010f/fgr223.json", + "isEmbedded": true, + "manufacturer": "Fibargroup", + "manufacturerId": 271, + "label": "FGR223", + "description": "Roller Shutter 3", + "devices": [ + { + "productType": 771, + "productId": 4096 + }, + { + "productType": 771, + "productId": 12288 + }, + { + "productType": 771, + "productId": 16384 + } + ], + "firmwareVersion": { + "min": "0.0", + "max": "255.255" + }, + "preferred": false, + "paramInformation": { + "_map": {} + }, + "proprietary": { + "fibaroCCs": [38] + } + }, + "label": "FGR223", + "endpointCountIsDynamic": false, + "endpointsHaveIdenticalCapabilities": false, + "individualEndpointCount": 2, + "aggregatedEndpointCount": 0, + "interviewAttempts": 0, + "endpoints": [ + { + "nodeId": 10, + "index": 0, + "installerIcon": 6400, + "userIcon": 6400, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 6, + "label": "Motor Control Class B" + }, + "mandatorySupportedCCs": [32, 38, 37, 114, 134], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 38, + "name": "Multilevel Switch", + "version": 4, + "isSecure": true + }, + { + "id": 114, + "name": "Manufacturer Specific", + "version": 2, + "isSecure": true + }, + { + "id": 134, + "name": "Version", + "version": 2, + "isSecure": true + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 85, + "name": "Transport Service", + "version": 2, + "isSecure": false + }, + { + "id": 152, + "name": "Security", + "version": 1, + "isSecure": true + }, + { + "id": 159, + "name": "Security 2", + "version": 1, + "isSecure": true + }, + { + "id": 86, + "name": "CRC-16 Encapsulation", + "version": 1, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": true + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": true + }, + { + "id": 89, + "name": "Association Group Information", + "version": 2, + "isSecure": true + }, + { + "id": 90, + "name": "Device Reset Locally", + "version": 1, + "isSecure": true + }, + { + "id": 115, + "name": "Powerlevel", + "version": 1, + "isSecure": true + }, + { + "id": 50, + "name": "Meter", + "version": 3, + "isSecure": true + }, + { + "id": 112, + "name": "Configuration", + "version": 1, + "isSecure": true + }, + { + "id": 113, + "name": "Notification", + "version": 8, + "isSecure": true + }, + { + "id": 117, + "name": "Protection", + "version": 2, + "isSecure": true + }, + { + "id": 96, + "name": "Multi Channel", + "version": 4, + "isSecure": true + }, + { + "id": 91, + "name": "Central Scene", + "version": 3, + "isSecure": true + }, + { + "id": 122, + "name": "Firmware Update Meta Data", + "version": 4, + "isSecure": true + } + ] + }, + { + "nodeId": 10, + "index": 1, + "installerIcon": 6400, + "userIcon": 6400, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 6, + "label": "Motor Control Class B" + }, + "mandatorySupportedCCs": [32, 38, 37, 114, 134], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 38, + "name": "Multilevel Switch", + "version": 4, + "isSecure": true + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 152, + "name": "Security", + "version": 1, + "isSecure": true + }, + { + "id": 159, + "name": "Security 2", + "version": 1, + "isSecure": true + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": true + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": true + }, + { + "id": 89, + "name": "Association Group Information", + "version": 2, + "isSecure": true + }, + { + "id": 50, + "name": "Meter", + "version": 3, + "isSecure": true + }, + { + "id": 113, + "name": "Notification", + "version": 8, + "isSecure": true + }, + { + "id": 37, + "name": "Binary Switch", + "version": 2, + "isSecure": false + } + ] + }, + { + "nodeId": 10, + "index": 2, + "installerIcon": 6400, + "userIcon": 6400, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 6, + "label": "Motor Control Class B" + }, + "mandatorySupportedCCs": [32, 38, 37, 114, 134], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 38, + "name": "Multilevel Switch", + "version": 4, + "isSecure": true + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 152, + "name": "Security", + "version": 1, + "isSecure": true + }, + { + "id": 159, + "name": "Security 2", + "version": 1, + "isSecure": true + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": true + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": true + }, + { + "id": 89, + "name": "Association Group Information", + "version": 2, + "isSecure": true + }, + { + "id": 37, + "name": "Binary Switch", + "version": 2, + "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": "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": 108, + "commandClassName": "Supervision", + "property": "ccSupported", + "propertyKey": 91, + "propertyName": "ccSupported", + "propertyKeyName": "91", + "ccVersion": 1, + "metadata": { + "type": "any", + "readable": true, + "writeable": true, + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 20, + "propertyName": "Switch type", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Switch type", + "default": 2, + "min": 0, + "max": 2, + "states": { + "0": "Momentary switches", + "1": "Toggle switches", + "2": "Single momentary switch (S1)" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Switch type" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 24, + "propertyName": "Inputs orientation", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Inputs orientation", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "default", + "1": "reversed" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Inputs orientation" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 25, + "propertyName": "Outputs orientation", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Outputs orientation", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "default", + "1": "reversed" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Outputs orientation" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 40, + "propertyKey": 1, + "propertyName": "S1 scenes: Pressed 1 time", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Send a Central Scene notification when S1 is pressed 1 time", + "label": "S1 scenes: Pressed 1 time", + "default": 0, + "min": 0, + "max": 1, + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true, + "name": "S1 scenes: Pressed 1 time", + "info": "Send a Central Scene notification when S1 is pressed 1 time" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 40, + "propertyKey": 2, + "propertyName": "S1 scenes: Pressed 2 times", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Send a Central Scene notification when S1 is pressed 2 times", + "label": "S1 scenes: Pressed 2 times", + "default": 0, + "min": 0, + "max": 1, + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true, + "name": "S1 scenes: Pressed 2 times", + "info": "Send a Central Scene notification when S1 is pressed 2 times" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 40, + "propertyKey": 4, + "propertyName": "S1 scenes: Pressed 3 time", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Send a Central Scene notification when S1 is pressed 3 times", + "label": "S1 scenes: Pressed 3 time", + "default": 0, + "min": 0, + "max": 1, + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true, + "name": "S1 scenes: Pressed 3 time", + "info": "Send a Central Scene notification when S1 is pressed 3 times" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 40, + "propertyKey": 8, + "propertyName": "S1 scenes: Hold down / Release", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Send a Central Scene notification when S1 is held down or released", + "label": "S1 scenes: Hold down / Release", + "default": 0, + "min": 0, + "max": 1, + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true, + "name": "S1 scenes: Hold down / Release", + "info": "Send a Central Scene notification when S1 is held down or released" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 41, + "propertyKey": 1, + "propertyName": "S2 scenes: Pressed 1 time", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Send a Central Scene notification when S2 is pressed 1 time", + "label": "S2 scenes: Pressed 1 time", + "default": 0, + "min": 0, + "max": 1, + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true, + "name": "S2 scenes: Pressed 1 time", + "info": "Send a Central Scene notification when S2 is pressed 1 time" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 41, + "propertyKey": 2, + "propertyName": "S2 scenes: Pressed 2 times", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Send a Central Scene notification when S2 is pressed 2 times", + "label": "S2 scenes: Pressed 2 times", + "default": 0, + "min": 0, + "max": 1, + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true, + "name": "S2 scenes: Pressed 2 times", + "info": "Send a Central Scene notification when S2 is pressed 2 times" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 41, + "propertyKey": 4, + "propertyName": "S2 scenes: Pressed 3 time", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Send a Central Scene notification when S2 is pressed 3 times", + "label": "S2 scenes: Pressed 3 time", + "default": 0, + "min": 0, + "max": 1, + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true, + "name": "S2 scenes: Pressed 3 time", + "info": "Send a Central Scene notification when S2 is pressed 3 times" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 41, + "propertyKey": 8, + "propertyName": "S2 scenes: Hold down / Release", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Send a Central Scene notification when S2 is held down or released", + "label": "S2 scenes: Hold down / Release", + "default": 0, + "min": 0, + "max": 1, + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true, + "name": "S2 scenes: Hold down / Release", + "info": "Send a Central Scene notification when S2 is held down or released" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 60, + "propertyName": "Measuring power consumed by the device itself", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Measuring power consumed by the device itself", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Function inactive", + "1": "Function active" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Measuring power consumed by the device itself" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 61, + "propertyName": "Power reports - on change", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Power reports - on change", + "default": 15, + "min": 0, + "max": 500, + "valueSize": 2, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Power reports - on change" + }, + "value": 15 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 62, + "propertyName": "Power reports - periodic", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Power reports - periodic", + "default": 3600, + "min": 0, + "max": 32400, + "valueSize": 2, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Power reports - periodic" + }, + "value": 3600 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 65, + "propertyName": "Energy reports - on change", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Energy reports - on change", + "default": 10, + "min": 0, + "max": 500, + "valueSize": 2, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Energy reports - on change" + }, + "value": 10 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 66, + "propertyName": "Energy reports - periodic", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Energy reports - periodic", + "default": 3600, + "min": 0, + "max": 32400, + "valueSize": 2, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Energy reports - periodic" + }, + "value": 3600 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 150, + "propertyName": "Force calibration", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Force calibration", + "default": 0, + "min": 0, + "max": 2, + "states": { + "0": "device is not calibrated", + "1": "device is calibrated", + "2": "force device calibration" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Force calibration" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 151, + "propertyName": "Operating mode", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Operating mode", + "default": 1, + "min": 1, + "max": 6, + "states": { + "1": "roller blind", + "2": "Venetian blind", + "3": "gate w/o positioning", + "4": "gate with positioning", + "5": "roller blind with built-in driver", + "6": "roller blind with built-in driver (impulse)" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Operating mode" + }, + "value": 2 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 152, + "propertyName": "Venetian blind - time of full turn of the slats", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Venetian blind - time of full turn of the slats", + "default": 150, + "min": 0, + "max": 65535, + "unit": "1/100 sec", + "valueSize": 4, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Venetian blind - time of full turn of the slats" + }, + "value": 150 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 153, + "propertyName": "Set slats back to previous position", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Set slats back to previous position", + "default": 1, + "min": 0, + "max": 2, + "states": { + "0": "Main controller operation", + "1": "Controller, Momentary Switch, Limit Switch", + "2": "Controller, both Switches, Multilevel Stop" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Set slats back to previous position" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 154, + "propertyName": "Delay motor stop", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Delay motor stop after reaching end switch", + "label": "Delay motor stop", + "default": 10, + "min": 0, + "max": 255, + "unit": "1/10 sec", + "valueSize": 2, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Delay motor stop", + "info": "Delay motor stop after reaching end switch" + }, + "value": 10 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 155, + "propertyName": "Motor operation detection", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Power threshold to be interpreted as reaching a limit switch", + "label": "Motor operation detection", + "default": 10, + "min": 0, + "max": 255, + "unit": "W", + "valueSize": 2, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Motor operation detection", + "info": "Power threshold to be interpreted as reaching a limit switch" + }, + "value": 10 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 156, + "propertyName": "Time of up movement", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Time of up movement", + "default": 6000, + "min": 0, + "max": 65535, + "unit": "1/100 sec", + "valueSize": 4, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Time of up movement" + }, + "value": 5000 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 157, + "propertyName": "Time of down movement", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Time of down movement", + "default": 6000, + "min": 0, + "max": 65535, + "unit": "1/100 sec", + "valueSize": 4, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Time of down movement" + }, + "value": 5000 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 30, + "propertyKey": 255, + "propertyName": "Alarm #1: Action", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Which action to perform when Alarm #1 is triggered", + "label": "Alarm #1: Action", + "default": 0, + "min": 0, + "max": 2, + "states": { + "0": "No action", + "1": "Open blinds", + "2": "Close blinds" + }, + "valueSize": 4, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Alarm #1: Action", + "info": "Which action to perform when Alarm #1 is triggered" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 30, + "propertyKey": 65280, + "propertyName": "Alarm #1: Event/State Parameters", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Which event parameters Alarm #1 should be limited to", + "label": "Alarm #1: Event/State Parameters", + "default": 0, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Alarm #1: Event/State Parameters", + "info": "Which event parameters Alarm #1 should be limited to" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 30, + "propertyKey": 16711680, + "propertyName": "Alarm #1: Notification Status", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Which notification status Alarm #1 should be limited to", + "label": "Alarm #1: Notification Status", + "default": 0, + "min": 0, + "max": 255, + "states": { + "255": "Any" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Alarm #1: Notification Status", + "info": "Which notification status Alarm #1 should be limited to" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 30, + "propertyKey": 4278190080, + "propertyName": "Alarm #1: Notification Type", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Which notification type should raise Alarm #1", + "label": "Alarm #1: Notification Type", + "default": 0, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Alarm #1: Notification Type", + "info": "Which notification type should raise Alarm #1" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 31, + "propertyKey": 255, + "propertyName": "Alarm #2: Action", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Which action to perform when Alarm #2 is triggered", + "label": "Alarm #2: Action", + "default": 0, + "min": 0, + "max": 2, + "states": { + "0": "No action", + "1": "Open blinds", + "2": "Close blinds" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Alarm #2: Action", + "info": "Which action to perform when Alarm #2 is triggered" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 31, + "propertyKey": 65280, + "propertyName": "Alarm #2: Event/State Parameters", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Which event parameters Alarm #2 should be limited to", + "label": "Alarm #2: Event/State Parameters", + "default": 0, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Alarm #2: Event/State Parameters", + "info": "Which event parameters Alarm #2 should be limited to" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 31, + "propertyKey": 16711680, + "propertyName": "Alarm #2: Notification Status", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Which notification status Alarm #2 should be limited to", + "label": "Alarm #2: Notification Status", + "default": 255, + "min": 0, + "max": 255, + "states": { + "255": "Any" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Alarm #2: Notification Status", + "info": "Which notification status Alarm #2 should be limited to" + }, + "value": 255 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 31, + "propertyKey": 4278190080, + "propertyName": "Alarm #2: Notification Type", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Which notification type should raise Alarm #2", + "label": "Alarm #2: Notification Type", + "default": 5, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Alarm #2: Notification Type", + "info": "Which notification type should raise Alarm #2" + }, + "value": 5 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 32, + "propertyKey": 255, + "propertyName": "Alarm #3: Action", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Which action to perform when Alarm #3 is triggered", + "label": "Alarm #3: Action", + "default": 0, + "min": 0, + "max": 2, + "states": { + "0": "No action", + "1": "Open blinds", + "2": "Close blinds" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Alarm #3: Action", + "info": "Which action to perform when Alarm #3 is triggered" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 32, + "propertyKey": 65280, + "propertyName": "Alarm #3: Event/State Parameters", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Which event parameters Alarm #3 should be limited to", + "label": "Alarm #3: Event/State Parameters", + "default": 0, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Alarm #3: Event/State Parameters", + "info": "Which event parameters Alarm #3 should be limited to" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 32, + "propertyKey": 16711680, + "propertyName": "Alarm #3: Notification Status", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Which notification status Alarm #3 should be limited to", + "label": "Alarm #3: Notification Status", + "default": 255, + "min": 0, + "max": 255, + "states": { + "255": "Any" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Alarm #3: Notification Status", + "info": "Which notification status Alarm #3 should be limited to" + }, + "value": 255 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 32, + "propertyKey": 4278190080, + "propertyName": "Alarm #3: Notification Type", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Which notification type should raise Alarm #3", + "label": "Alarm #3: Notification Type", + "default": 1, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Alarm #3: Notification Type", + "info": "Which notification type should raise Alarm #3" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 33, + "propertyKey": 255, + "propertyName": "Alarm #4: Action", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Which action to perform when Alarm #4 is triggered", + "label": "Alarm #4: Action", + "default": 0, + "min": 0, + "max": 2, + "states": { + "0": "No action", + "1": "Open blinds", + "2": "Close blinds" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Alarm #4: Action", + "info": "Which action to perform when Alarm #4 is triggered" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 33, + "propertyKey": 65280, + "propertyName": "Alarm #4: Event/State Parameters", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Which event parameters Alarm #4 should be limited to", + "label": "Alarm #4: Event/State Parameters", + "default": 0, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Alarm #4: Event/State Parameters", + "info": "Which event parameters Alarm #4 should be limited to" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 33, + "propertyKey": 16711680, + "propertyName": "Alarm #4: Notification Status", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Which notification status Alarm #4 should be limited to", + "label": "Alarm #4: Notification Status", + "default": 255, + "min": 0, + "max": 255, + "states": { + "255": "Any" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Alarm #4: Notification Status", + "info": "Which notification status Alarm #4 should be limited to" + }, + "value": 255 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 33, + "propertyKey": 4278190080, + "propertyName": "Alarm #4: Notification Type", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Which notification type should raise Alarm #4", + "label": "Alarm #4: Notification Type", + "default": 2, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Alarm #4: Notification Type", + "info": "Which notification type should raise Alarm #4" + }, + "value": 2 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 34, + "propertyKey": 255, + "propertyName": "Alarm #5: Action", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Which action to perform when Alarm #5 is triggered", + "label": "Alarm #5: Action", + "default": 0, + "min": 0, + "max": 2, + "states": { + "0": "No action", + "1": "Open blinds", + "2": "Close blinds" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Alarm #5: Action", + "info": "Which action to perform when Alarm #5 is triggered" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 34, + "propertyKey": 65280, + "propertyName": "Alarm #5: Event/State Parameters", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Which event parameters Alarm #5 should be limited to", + "label": "Alarm #5: Event/State Parameters", + "default": 0, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Alarm #5: Event/State Parameters", + "info": "Which event parameters Alarm #5 should be limited to" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 34, + "propertyKey": 16711680, + "propertyName": "Alarm #5: Notification Status", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Which notification status Alarm #5 should be limited to", + "label": "Alarm #5: Notification Status", + "default": 255, + "min": 0, + "max": 255, + "states": { + "255": "Any" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Alarm #5: Notification Status", + "info": "Which notification status Alarm #5 should be limited to" + }, + "value": 255 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 34, + "propertyKey": 4278190080, + "propertyName": "Alarm #5: Notification Type", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Which notification type should raise Alarm #5", + "label": "Alarm #5: Notification Type", + "default": 4, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Alarm #5: Notification Type", + "info": "Which notification type should raise Alarm #5" + }, + "value": 4 + }, + { + "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": 271 + }, + { + "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": 771 + }, + { + "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": 4096 + }, + { + "endpoint": 0, + "commandClass": 117, + "commandClassName": "Protection", + "property": "local", + "propertyName": "local", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Local protection state", + "states": { + "0": "Unprotected", + "2": "NoOperationPossible" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 117, + "commandClassName": "Protection", + "property": "rf", + "propertyName": "rf", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "RF protection state", + "states": { + "0": "Unprotected", + "1": "NoControl" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 117, + "commandClassName": "Protection", + "property": "exclusiveControlNodeId", + "propertyName": "exclusiveControlNodeId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Node ID with exclusive control", + "min": 1, + "max": 232, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 117, + "commandClassName": "Protection", + "property": "timeout", + "propertyName": "timeout", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "RF protection timeout", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "libraryType", + "propertyName": "libraryType", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Library type", + "states": { + "0": "Unknown", + "1": "Static Controller", + "2": "Controller", + "3": "Enhanced Slave", + "4": "Slave", + "5": "Installer", + "6": "Routing Slave", + "7": "Bridge Controller", + "8": "Device under Test", + "9": "N/A", + "10": "AV Remote", + "11": "AV Device" + }, + "stateful": true, + "secret": false + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "protocolVersion", + "propertyName": "protocolVersion", + "ccVersion": 2, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version", + "stateful": true, + "secret": false + }, + "value": "6.2" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "firmwareVersions", + "propertyName": "firmwareVersions", + "ccVersion": 2, + "metadata": { + "type": "string[]", + "readable": true, + "writeable": false, + "label": "Z-Wave chip firmware versions", + "stateful": true, + "secret": false + }, + "value": ["5.1", "5.1"] + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hardwareVersion", + "propertyName": "hardwareVersion", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Z-Wave chip hardware version", + "stateful": true, + "secret": false + }, + "value": 3 + }, + { + "endpoint": 1, + "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": "unknown" + }, + { + "endpoint": 1, + "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": 99 + }, + { + "endpoint": 1, + "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": 99 + }, + { + "endpoint": 1, + "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 + }, + "value": false + }, + { + "endpoint": 1, + "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": 1, + "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 + } + }, + { + "endpoint": 1, + "commandClass": 50, + "commandClassName": "Meter", + "property": "value", + "propertyKey": 65537, + "propertyName": "value", + "propertyKeyName": "Electric_kWh_Consumed", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric Consumption [kWh]", + "ccSpecific": { + "meterType": 1, + "scale": 0, + "rateType": 1 + }, + "unit": "kWh", + "stateful": true, + "secret": false + }, + "value": 0.0 + }, + { + "endpoint": 1, + "commandClass": 50, + "commandClassName": "Meter", + "property": "value", + "propertyKey": 66049, + "propertyName": "value", + "propertyKeyName": "Electric_W_Consumed", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric Consumption [W]", + "ccSpecific": { + "meterType": 1, + "scale": 2, + "rateType": 1 + }, + "unit": "W", + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 1, + "commandClass": 50, + "commandClassName": "Meter", + "property": "reset", + "propertyName": "reset", + "ccVersion": 3, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Reset accumulated values", + "states": { + "true": "Reset" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 1, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Power Management", + "propertyKey": "Over-current status", + "propertyName": "Power Management", + "propertyKeyName": "Over-current status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Over-current status", + "ccSpecific": { + "notificationType": 8 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "6": "Over-current detected" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 1, + "commandClass": 113, + "commandClassName": "Notification", + "property": "System", + "propertyKey": "Hardware status", + "propertyName": "System", + "propertyKeyName": "Hardware status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Hardware status", + "ccSpecific": { + "notificationType": 9 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "3": "System hardware failure (with failure code)" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 1, + "commandClass": 113, + "commandClassName": "Notification", + "property": "alarmType", + "propertyName": "alarmType", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm Type", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 1, + "commandClass": 113, + "commandClassName": "Notification", + "property": "alarmLevel", + "propertyName": "alarmLevel", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm Level", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 2, + "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": "unknown" + }, + { + "endpoint": 2, + "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": 2, + "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": 2, + "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 + }, + "value": false + }, + { + "endpoint": 2, + "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": 2, + "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": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 6, + "label": "Motor Control Class B" + }, + "mandatorySupportedCCs": [32, 38, 37, 114, 134], + "mandatoryControlledCCs": [] + }, + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x010f:0x0303:0x1000:5.1", + "statistics": { + "commandsTX": 8, + "commandsRX": 13, + "commandsDroppedRX": 12, + "commandsDroppedTX": 0, + "timeoutResponse": 1, + "rtt": 155.4, + "rssi": -66, + "lwr": { + "protocolDataRate": 2, + "repeaters": [11], + "rssi": -56, + "repeaterRSSI": [-55] + }, + "nlwr": { + "protocolDataRate": 3, + "repeaters": [], + "rssi": -89, + "repeaterRSSI": [] + } + }, + "highestSecurityClass": 1, + "isControllerNode": false, + "keepAwake": false +} 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 00000000000..b570e9cea34 --- /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 02ed507cabe..965b1ea4f1b 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -126,12 +126,14 @@ async def test_network_status( hass: HomeAssistant, multisensor_6, controller_state, + client, integration, hass_ws_client: WebSocketGenerator, ) -> None: """Test the network status websocket command.""" entry = integration ws_client = await hass_ws_client(hass) + client.server_logging_enabled = False # Try API call with entry ID with patch( @@ -150,6 +152,7 @@ async def test_network_status( assert result["client"]["ws_server_url"] == "ws://test:3000/zjs" assert result["client"]["server_version"] == "1.0.0" + assert not result["client"]["server_logging_enabled"] assert result["controller"]["inclusion_state"] == InclusionState.IDLE # Try API call with device ID @@ -906,7 +909,7 @@ async def test_add_node( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) @@ -1179,7 +1182,7 @@ async def test_provision_smart_start_node( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) @@ -1283,7 +1286,7 @@ async def test_unprovision_smart_start_node( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) @@ -1355,7 +1358,7 @@ async def test_get_provisioning_entries( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) @@ -1450,7 +1453,7 @@ async def test_parse_qr_code_string( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) @@ -1517,7 +1520,7 @@ async def test_try_parse_dsk_from_qr_code_string( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) @@ -1599,7 +1602,7 @@ async def test_cancel_inclusion_exclusion( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" # Test FailedZWaveCommand is caught with patch( @@ -1617,7 +1620,7 @@ async def test_cancel_inclusion_exclusion( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) @@ -1736,7 +1739,7 @@ async def test_remove_node( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) @@ -2081,7 +2084,7 @@ async def test_replace_failed_node( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) @@ -2132,7 +2135,7 @@ async def test_remove_failed_node( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" await ws_client.send_json( { @@ -2187,13 +2190,13 @@ async def test_remove_failed_node( assert msg["error"]["code"] == ERR_NOT_LOADED -async def test_begin_healing_network( +async def test_begin_rebuilding_routes( hass: HomeAssistant, integration, client, hass_ws_client: WebSocketGenerator, ) -> None: - """Test the begin_healing_network websocket command.""" + """Test the begin_rebuilding_routes websocket command.""" entry = integration ws_client = await hass_ws_client(hass) @@ -2202,7 +2205,7 @@ async def test_begin_healing_network( await ws_client.send_json( { ID: 3, - TYPE: "zwave_js/begin_healing_network", + TYPE: "zwave_js/begin_rebuilding_routes", ENTRY_ID: entry.entry_id, } ) @@ -2213,13 +2216,13 @@ async def test_begin_healing_network( # Test FailedZWaveCommand is caught with patch( - f"{CONTROLLER_PATCH_PREFIX}.async_begin_healing_network", + f"{CONTROLLER_PATCH_PREFIX}.async_begin_rebuilding_routes", side_effect=FailedZWaveCommand("failed_command", 1, "error message"), ): await ws_client.send_json( { ID: 4, - TYPE: "zwave_js/begin_healing_network", + TYPE: "zwave_js/begin_rebuilding_routes", ENTRY_ID: entry.entry_id, } ) @@ -2227,7 +2230,7 @@ async def test_begin_healing_network( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) @@ -2236,7 +2239,7 @@ async def test_begin_healing_network( await ws_client.send_json( { ID: 5, - TYPE: "zwave_js/begin_healing_network", + TYPE: "zwave_js/begin_rebuilding_routes", ENTRY_ID: entry.entry_id, } ) @@ -2246,17 +2249,21 @@ async def test_begin_healing_network( assert msg["error"]["code"] == ERR_NOT_LOADED -async def test_subscribe_heal_network_progress( - hass: HomeAssistant, integration, client, hass_ws_client: WebSocketGenerator +async def test_subscribe_rebuild_routes_progress( + hass: HomeAssistant, + integration, + client, + nortek_thermostat, + hass_ws_client: WebSocketGenerator, ) -> None: - """Test the subscribe_heal_network_progress command.""" + """Test the subscribe_rebuild_routes_progress command.""" entry = integration ws_client = await hass_ws_client(hass) await ws_client.send_json( { ID: 3, - TYPE: "zwave_js/subscribe_heal_network_progress", + TYPE: "zwave_js/subscribe_rebuild_routes_progress", ENTRY_ID: entry.entry_id, } ) @@ -2265,19 +2272,19 @@ async def test_subscribe_heal_network_progress( assert msg["success"] assert msg["result"] is None - # Fire heal network progress + # Fire rebuild routes progress event = Event( - "heal network progress", + "rebuild routes progress", { "source": "controller", - "event": "heal network progress", + "event": "rebuild routes progress", "progress": {67: "pending"}, }, ) client.driver.controller.receive_event(event) msg = await ws_client.receive_json() - assert msg["event"]["event"] == "heal network progress" - assert msg["event"]["heal_node_status"] == {"67": "pending"} + assert msg["event"]["event"] == "rebuild routes progress" + assert msg["event"]["rebuild_routes_status"] == {"67": "pending"} # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) @@ -2286,7 +2293,7 @@ async def test_subscribe_heal_network_progress( await ws_client.send_json( { ID: 4, - TYPE: "zwave_js/subscribe_heal_network_progress", + TYPE: "zwave_js/subscribe_rebuild_routes_progress", ENTRY_ID: entry.entry_id, } ) @@ -2296,21 +2303,25 @@ async def test_subscribe_heal_network_progress( assert msg["error"]["code"] == ERR_NOT_LOADED -async def test_subscribe_heal_network_progress_initial_value( - hass: HomeAssistant, integration, client, hass_ws_client: WebSocketGenerator +async def test_subscribe_rebuild_routes_progress_initial_value( + hass: HomeAssistant, + integration, + client, + nortek_thermostat, + hass_ws_client: WebSocketGenerator, ) -> None: - """Test subscribe_heal_network_progress command when heal network in progress.""" + """Test subscribe_rebuild_routes_progress command when rebuild routes in progress.""" entry = integration ws_client = await hass_ws_client(hass) - assert not client.driver.controller.heal_network_progress + assert not client.driver.controller.rebuild_routes_progress - # Fire heal network progress before sending heal network progress command + # Fire rebuild routes progress before sending rebuild routes progress command event = Event( - "heal network progress", + "rebuild routes progress", { "source": "controller", - "event": "heal network progress", + "event": "rebuild routes progress", "progress": {67: "pending"}, }, ) @@ -2319,7 +2330,7 @@ async def test_subscribe_heal_network_progress_initial_value( await ws_client.send_json( { ID: 3, - TYPE: "zwave_js/subscribe_heal_network_progress", + TYPE: "zwave_js/subscribe_rebuild_routes_progress", ENTRY_ID: entry.entry_id, } ) @@ -2329,13 +2340,13 @@ async def test_subscribe_heal_network_progress_initial_value( assert msg["result"] == {"67": "pending"} -async def test_stop_healing_network( +async def test_stop_rebuilding_routes( hass: HomeAssistant, integration, client, hass_ws_client: WebSocketGenerator, ) -> None: - """Test the stop_healing_network websocket command.""" + """Test the stop_rebuilding_routes websocket command.""" entry = integration ws_client = await hass_ws_client(hass) @@ -2344,7 +2355,7 @@ async def test_stop_healing_network( await ws_client.send_json( { ID: 3, - TYPE: "zwave_js/stop_healing_network", + TYPE: "zwave_js/stop_rebuilding_routes", ENTRY_ID: entry.entry_id, } ) @@ -2355,13 +2366,13 @@ async def test_stop_healing_network( # Test FailedZWaveCommand is caught with patch( - f"{CONTROLLER_PATCH_PREFIX}.async_stop_healing_network", + f"{CONTROLLER_PATCH_PREFIX}.async_stop_rebuilding_routes", side_effect=FailedZWaveCommand("failed_command", 1, "error message"), ): await ws_client.send_json( { ID: 4, - TYPE: "zwave_js/stop_healing_network", + TYPE: "zwave_js/stop_rebuilding_routes", ENTRY_ID: entry.entry_id, } ) @@ -2369,7 +2380,7 @@ async def test_stop_healing_network( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) @@ -2378,7 +2389,7 @@ async def test_stop_healing_network( await ws_client.send_json( { ID: 5, - TYPE: "zwave_js/stop_healing_network", + TYPE: "zwave_js/stop_rebuilding_routes", ENTRY_ID: entry.entry_id, } ) @@ -2388,14 +2399,14 @@ async def test_stop_healing_network( assert msg["error"]["code"] == ERR_NOT_LOADED -async def test_heal_node( +async def test_rebuild_node_routes( hass: HomeAssistant, multisensor_6, integration, client, hass_ws_client: WebSocketGenerator, ) -> None: - """Test the heal_node websocket command.""" + """Test the rebuild_node_routes websocket command.""" entry = integration ws_client = await hass_ws_client(hass) device = get_device(hass, multisensor_6) @@ -2405,7 +2416,7 @@ async def test_heal_node( await ws_client.send_json( { ID: 3, - TYPE: "zwave_js/heal_node", + TYPE: "zwave_js/rebuild_node_routes", DEVICE_ID: device.id, } ) @@ -2416,13 +2427,13 @@ async def test_heal_node( # Test FailedZWaveCommand is caught with patch( - f"{CONTROLLER_PATCH_PREFIX}.async_heal_node", + f"{CONTROLLER_PATCH_PREFIX}.async_rebuild_node_routes", side_effect=FailedZWaveCommand("failed_command", 1, "error message"), ): await ws_client.send_json( { ID: 4, - TYPE: "zwave_js/heal_node", + TYPE: "zwave_js/rebuild_node_routes", DEVICE_ID: device.id, } ) @@ -2430,7 +2441,7 @@ async def test_heal_node( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) @@ -2439,7 +2450,7 @@ async def test_heal_node( await ws_client.send_json( { ID: 5, - TYPE: "zwave_js/heal_node", + TYPE: "zwave_js/rebuild_node_routes", DEVICE_ID: device.id, } ) @@ -2558,7 +2569,7 @@ async def test_refresh_node_info( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) @@ -2635,7 +2646,7 @@ async def test_refresh_node_values( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) @@ -2729,7 +2740,7 @@ async def test_refresh_node_cc_values( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) @@ -2954,7 +2965,7 @@ async def test_set_config_parameter( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) @@ -3312,7 +3323,7 @@ async def test_subscribe_log_updates( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) @@ -3465,7 +3476,7 @@ async def test_update_log_config( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) @@ -3569,13 +3580,10 @@ async def test_data_collection( result = msg["result"] assert result is None - assert len(client.async_send_command.call_args_list) == 2 + assert len(client.async_send_command.call_args_list) == 1 args = client.async_send_command.call_args_list[0][0][0] assert args["command"] == "driver.enable_statistics" assert args["applicationName"] == "Home Assistant" - args = client.async_send_command.call_args_list[1][0][0] - assert args["command"] == "driver.enable_error_reporting" - assert entry.data[CONF_DATA_COLLECTION_OPTED_IN] client.async_send_command.reset_mock() @@ -3616,7 +3624,7 @@ async def test_data_collection( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" # Test FailedZWaveCommand is caught with patch( @@ -3635,7 +3643,7 @@ async def test_data_collection( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) @@ -3710,7 +3718,7 @@ async def test_abort_firmware_update( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) @@ -3787,7 +3795,7 @@ async def test_is_node_firmware_update_in_progress( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) @@ -4153,7 +4161,7 @@ async def test_get_node_firmware_update_capabilities( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) @@ -4224,7 +4232,7 @@ async def test_is_any_ota_firmware_update_in_progress( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) @@ -4300,7 +4308,7 @@ async def test_check_for_config_updates( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) @@ -4367,7 +4375,7 @@ async def test_install_config_update( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index 73dd82d5f4b..a051f398d8c 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_cover.py b/tests/components/zwave_js/test_cover.py index e51b3751ac8..fc593de883b 100644 --- a/tests/components/zwave_js/test_cover.py +++ b/tests/components/zwave_js/test_cover.py @@ -47,7 +47,8 @@ GDC_COVER_ENTITY = "cover.aeon_labs_garage_door_controller_gen5" BLIND_COVER_ENTITY = "cover.window_blind_controller" SHUTTER_COVER_ENTITY = "cover.flush_shutter" AEOTEC_SHUTTER_COVER_ENTITY = "cover.nano_shutter_v_3" -FIBARO_SHUTTER_COVER_ENTITY = "cover.fgr_222_test_cover" +FIBARO_FGR_222_SHUTTER_COVER_ENTITY = "cover.fgr_222_test_cover" +FIBARO_FGR_223_SHUTTER_COVER_ENTITY = "cover.fgr_223_test_cover" LOGGER.setLevel(logging.DEBUG) @@ -238,7 +239,7 @@ async def test_fibaro_fgr222_shutter_cover( hass: HomeAssistant, client, fibaro_fgr222_shutter, integration ) -> None: """Test tilt function of the Fibaro Shutter devices.""" - state = hass.states.get(FIBARO_SHUTTER_COVER_ENTITY) + state = hass.states.get(FIBARO_FGR_222_SHUTTER_COVER_ENTITY) assert state assert state.attributes[ATTR_DEVICE_CLASS] == CoverDeviceClass.SHUTTER @@ -249,7 +250,7 @@ async def test_fibaro_fgr222_shutter_cover( await hass.services.async_call( DOMAIN, SERVICE_OPEN_COVER_TILT, - {ATTR_ENTITY_ID: FIBARO_SHUTTER_COVER_ENTITY}, + {ATTR_ENTITY_ID: FIBARO_FGR_222_SHUTTER_COVER_ENTITY}, blocking=True, ) @@ -271,7 +272,7 @@ async def test_fibaro_fgr222_shutter_cover( await hass.services.async_call( DOMAIN, SERVICE_CLOSE_COVER_TILT, - {ATTR_ENTITY_ID: FIBARO_SHUTTER_COVER_ENTITY}, + {ATTR_ENTITY_ID: FIBARO_FGR_222_SHUTTER_COVER_ENTITY}, blocking=True, ) @@ -293,7 +294,7 @@ async def test_fibaro_fgr222_shutter_cover( await hass.services.async_call( DOMAIN, SERVICE_SET_COVER_TILT_POSITION, - {ATTR_ENTITY_ID: FIBARO_SHUTTER_COVER_ENTITY, ATTR_TILT_POSITION: 12}, + {ATTR_ENTITY_ID: FIBARO_FGR_222_SHUTTER_COVER_ENTITY, ATTR_TILT_POSITION: 12}, blocking=True, ) @@ -330,7 +331,101 @@ async def test_fibaro_fgr222_shutter_cover( }, ) fibaro_fgr222_shutter.receive_event(event) - state = hass.states.get(FIBARO_SHUTTER_COVER_ENTITY) + state = hass.states.get(FIBARO_FGR_222_SHUTTER_COVER_ENTITY) + assert state + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 100 + + +async def test_fibaro_fgr223_shutter_cover( + hass: HomeAssistant, client, fibaro_fgr223_shutter, integration +) -> None: + """Test tilt function of the Fibaro Shutter devices.""" + state = hass.states.get(FIBARO_FGR_223_SHUTTER_COVER_ENTITY) + assert state + assert state.attributes[ATTR_DEVICE_CLASS] == CoverDeviceClass.SHUTTER + + assert state.state == STATE_OPEN + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 0 + + # Test opening tilts + await hass.services.async_call( + DOMAIN, + SERVICE_OPEN_COVER_TILT, + {ATTR_ENTITY_ID: FIBARO_FGR_223_SHUTTER_COVER_ENTITY}, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == 10 + assert args["valueId"] == { + "endpoint": 2, + "commandClass": 38, + "property": "targetValue", + } + assert args["value"] == 99 + + client.async_send_command.reset_mock() + # Test closing tilts + await hass.services.async_call( + DOMAIN, + SERVICE_CLOSE_COVER_TILT, + {ATTR_ENTITY_ID: FIBARO_FGR_223_SHUTTER_COVER_ENTITY}, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == 10 + assert args["valueId"] == { + "endpoint": 2, + "commandClass": 38, + "property": "targetValue", + } + assert args["value"] == 0 + + client.async_send_command.reset_mock() + # Test setting tilt position + await hass.services.async_call( + DOMAIN, + SERVICE_SET_COVER_TILT_POSITION, + {ATTR_ENTITY_ID: FIBARO_FGR_223_SHUTTER_COVER_ENTITY, ATTR_TILT_POSITION: 12}, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == 10 + assert args["valueId"] == { + "endpoint": 2, + "commandClass": 38, + "property": "targetValue", + } + assert args["value"] == 12 + + # Test some tilt + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": 10, + "args": { + "commandClassName": "Multilevel Switch", + "commandClass": 38, + "endpoint": 2, + "property": "currentValue", + "newValue": 99, + "prevValue": 0, + "propertyName": "currentValue", + }, + }, + ) + fibaro_fgr223_shutter.receive_event(event) + state = hass.states.get(FIBARO_FGR_223_SHUTTER_COVER_ENTITY) assert state assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 100 @@ -694,13 +789,42 @@ async def test_fibaro_fgr222_shutter_cover_no_tilt( client.driver.controller.emit("node added", {"node": node}) await hass.async_block_till_done() - state = hass.states.get(FIBARO_SHUTTER_COVER_ENTITY) + state = hass.states.get(FIBARO_FGR_222_SHUTTER_COVER_ENTITY) assert state assert state.state == STATE_UNKNOWN assert ATTR_CURRENT_POSITION not in state.attributes assert ATTR_CURRENT_TILT_POSITION not in state.attributes +async def test_fibaro_fgr223_shutter_cover_no_tilt( + hass: HomeAssistant, client, fibaro_fgr223_shutter_state, integration +) -> None: + """Test absence of tilt function for Fibaro Shutter roller blind. + + Fibaro Shutter devices can have operating mode set to roller blind (1). + """ + node_state = replace_value_of_zwave_value( + fibaro_fgr223_shutter_state, + [ + ZwaveValueMatcher( + property_=151, + command_class=CommandClass.CONFIGURATION, + endpoint=0, + ), + ], + 1, + ) + node = Node(client, node_state) + client.driver.controller.emit("node added", {"node": node}) + await hass.async_block_till_done() + + state = hass.states.get(FIBARO_FGR_223_SHUTTER_COVER_ENTITY) + assert state + assert state.state == STATE_OPEN + assert ATTR_CURRENT_POSITION in state.attributes + assert ATTR_CURRENT_TILT_POSITION not in state.attributes + + async def test_iblinds_v3_cover( hass: HomeAssistant, client, iblinds_v3, integration ) -> None: diff --git a/tests/components/zwave_js/test_device_trigger.py b/tests/components/zwave_js/test_device_trigger.py index fec9ec4cbbb..ba0bbbe087d 100644 --- a/tests/components/zwave_js/test_device_trigger.py +++ b/tests/components/zwave_js/test_device_trigger.py @@ -144,6 +144,7 @@ async def test_if_notification_notification_fires( "source": "node", "event": "notification", "nodeId": node.node_id, + "endpointIndex": 0, "ccId": 113, "args": { "type": 6, @@ -273,6 +274,7 @@ async def test_if_entry_control_notification_fires( "source": "node", "event": "notification", "nodeId": node.node_id, + "endpointIndex": 0, "ccId": 111, "args": { "eventType": 5, diff --git a/tests/components/zwave_js/test_events.py b/tests/components/zwave_js/test_events.py index e831e1dc7e8..80b179248d8 100644 --- a/tests/components/zwave_js/test_events.py +++ b/tests/components/zwave_js/test_events.py @@ -156,6 +156,7 @@ async def test_notifications( "source": "node", "event": "notification", "nodeId": 32, + "endpointIndex": 0, "ccId": 113, "args": { "type": 6, @@ -172,6 +173,7 @@ async def test_notifications( assert len(events) == 1 assert events[0].data["home_id"] == client.driver.controller.home_id assert events[0].data["node_id"] == 32 + assert events[0].data["endpoint"] == 0 assert events[0].data["type"] == 6 assert events[0].data["event"] == 5 assert events[0].data["label"] == "Access Control" @@ -187,6 +189,7 @@ async def test_notifications( "source": "node", "event": "notification", "nodeId": 32, + "endpointIndex": 0, "ccId": 111, "args": { "eventType": 5, @@ -204,6 +207,7 @@ async def test_notifications( assert len(events) == 2 assert events[1].data["home_id"] == client.driver.controller.home_id assert events[1].data["node_id"] == 32 + assert events[0].data["endpoint"] == 0 assert events[1].data["event_type"] == 5 assert events[1].data["event_type_label"] == "test1" assert events[1].data["data_type"] == 2 @@ -219,6 +223,7 @@ async def test_notifications( "source": "node", "event": "notification", "nodeId": 32, + "endpointIndex": 0, "ccId": 38, "args": {"eventType": 4, "eventTypeLabel": "test1", "direction": "up"}, }, @@ -230,6 +235,7 @@ async def test_notifications( assert len(events) == 3 assert events[2].data["home_id"] == client.driver.controller.home_id assert events[2].data["node_id"] == 32 + assert events[0].data["endpoint"] == 0 assert events[2].data["event_type"] == 4 assert events[2].data["event_type_label"] == "test1" assert events[2].data["direction"] == "up" @@ -320,6 +326,7 @@ async def test_power_level_notification( "source": "node", "event": "notification", "nodeId": 7, + "endpointIndex": 0, "ccId": 115, "args": { "commandClassName": "Powerlevel", @@ -363,6 +370,7 @@ async def test_unknown_notification( "source": "node", "event": "notification", "nodeId": node.node_id, + "endpointIndex": 0, "ccId": 0, "args": { "commandClassName": "No Operation", diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index 6985a7bf252..1203997839e 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -1,6 +1,7 @@ """Test the Z-Wave JS init module.""" import asyncio from copy import deepcopy +import logging from unittest.mock import AsyncMock, call, patch import pytest @@ -11,6 +12,7 @@ from zwave_js_server.model.node import Node from zwave_js_server.model.version import VersionInfo from homeassistant.components.hassio.handler import HassioAPIError +from homeassistant.components.logger import DOMAIN as LOGGER_DOMAIN, SERVICE_SET_LEVEL from homeassistant.components.persistent_notification import async_dismiss from homeassistant.components.zwave_js import DOMAIN from homeassistant.components.zwave_js.helpers import get_device_id @@ -23,6 +25,7 @@ from homeassistant.helpers import ( entity_registry as er, issue_registry as ir, ) +from homeassistant.setup import async_setup_component from .common import AIR_TEMPERATURE_SENSOR, EATON_RF9640_ENTITY @@ -1550,3 +1553,94 @@ async def test_identify_event( assert len(notifications) == 1 assert list(notifications)[0] == msg_id assert "network with the home ID `3245146787`" in notifications[msg_id]["message"] + + +async def test_server_logging(hass: HomeAssistant, client) -> None: + """Test automatic server logging functionality.""" + + def _reset_mocks(): + client.async_send_command.reset_mock() + client.enable_server_logging.reset_mock() + client.disable_server_logging.reset_mock() + + # Set server logging to disabled + client.server_logging_enabled = False + + entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + # Setup logger and set log level to debug to trigger event listener + assert await async_setup_component(hass, "logger", {"logger": {}}) + assert logging.getLogger("zwave_js_server").getEffectiveLevel() == logging.INFO + client.async_send_command.reset_mock() + await hass.services.async_call( + LOGGER_DOMAIN, SERVICE_SET_LEVEL, {"zwave_js_server": "debug"}, blocking=True + ) + await hass.async_block_till_done() + assert logging.getLogger("zwave_js_server").getEffectiveLevel() == logging.DEBUG + + # Validate that the server logging was enabled + assert len(client.async_send_command.call_args_list) == 1 + assert client.async_send_command.call_args[0][0] == { + "command": "driver.update_log_config", + "config": {"level": "debug"}, + } + assert client.enable_server_logging.called + assert not client.disable_server_logging.called + + _reset_mocks() + + # Emulate server by setting log level to debug + event = Event( + type="log config updated", + data={ + "source": "driver", + "event": "log config updated", + "config": { + "enabled": False, + "level": "debug", + "logToFile": True, + "filename": "test", + "forceConsole": True, + }, + }, + ) + client.driver.receive_event(event) + + # "Enable" server logging and unload the entry + client.server_logging_enabled = True + await hass.config_entries.async_unload(entry.entry_id) + + # Validate that the server logging was disabled + assert len(client.async_send_command.call_args_list) == 1 + assert client.async_send_command.call_args[0][0] == { + "command": "driver.update_log_config", + "config": {"level": "info"}, + } + assert not client.enable_server_logging.called + assert client.disable_server_logging.called + + _reset_mocks() + + # Validate that the server logging doesn't get enabled because HA thinks it already + # is enabled + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert len(client.async_send_command.call_args_list) == 0 + assert not client.enable_server_logging.called + assert not client.disable_server_logging.called + + _reset_mocks() + + # "Disable" server logging and unload the entry + client.server_logging_enabled = False + await hass.config_entries.async_unload(entry.entry_id) + + # Validate that the server logging was not disabled because HA thinks it is already + # is disabled + assert len(client.async_send_command.call_args_list) == 0 + assert not client.enable_server_logging.called + assert not client.disable_server_logging.called diff --git a/tests/components/zwave_js/test_light.py b/tests/components/zwave_js/test_light.py index 3a862ee3a0c..4b0345b00ea 100644 --- a/tests/components/zwave_js/test_light.py +++ b/tests/components/zwave_js/test_light.py @@ -35,6 +35,7 @@ from .common import ( ) 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_update.py b/tests/components/zwave_js/test_update.py index 9314b9155f5..46dca7a35ec 100644 --- a/tests/components/zwave_js/test_update.py +++ b/tests/components/zwave_js/test_update.py @@ -38,24 +38,72 @@ UPDATE_ENTITY = "update.z_wave_thermostat_firmware" LATEST_VERSION_FIRMWARE = { "version": "11.2.4", "changelog": "blah 2", + "channel": "stable", "files": [{"target": 0, "url": "https://example2.com", "integrity": "sha2"}], + "downgrade": True, + "normalizedVersion": "11.2.4", + "device": { + "manufacturerId": 1, + "productType": 2, + "productId": 3, + "firmwareVersion": "0.4.4", + "rfRegion": 1, + }, } FIRMWARE_UPDATES = { "updates": [ { "version": "10.11.1", "changelog": "blah 1", + "channel": "stable", "files": [ {"target": 0, "url": "https://example1.com", "integrity": "sha1"} ], + "downgrade": True, + "normalizedVersion": "10.11.1", + "device": { + "manufacturerId": 1, + "productType": 2, + "productId": 3, + "firmwareVersion": "0.4.4", + "rfRegion": 1, + }, }, LATEST_VERSION_FIRMWARE, { "version": "11.1.5", "changelog": "blah 3", + "channel": "stable", "files": [ {"target": 0, "url": "https://example3.com", "integrity": "sha3"} ], + "downgrade": True, + "normalizedVersion": "11.1.5", + "device": { + "manufacturerId": 1, + "productType": 2, + "productId": 3, + "firmwareVersion": "0.4.4", + "rfRegion": 1, + }, + }, + # This firmware update should never show because it's in the beta channel + { + "version": "999.999.999", + "changelog": "blah 3", + "channel": "beta", + "files": [ + {"target": 0, "url": "https://example3.com", "integrity": "sha3"} + ], + "downgrade": True, + "normalizedVersion": "999.999.999", + "device": { + "manufacturerId": 1, + "productType": 2, + "productId": 3, + "firmwareVersion": "0.4.4", + "rfRegion": 1, + }, }, ] } @@ -745,7 +793,23 @@ async def test_update_entity_full_restore_data_update_available( assert client.async_send_command.call_args_list[1][0][0] == { "command": "controller.firmware_update_ota", "nodeId": climate_radio_thermostat_ct100_plus_different_endpoints.node_id, - "updates": [{"target": 0, "url": "https://example2.com", "integrity": "sha2"}], + "updateInfo": { + "version": "11.2.4", + "changelog": "blah 2", + "channel": "stable", + "files": [ + {"target": 0, "url": "https://example2.com", "integrity": "sha2"} + ], + "downgrade": True, + "normalizedVersion": "11.2.4", + "device": { + "manufacturerId": 1, + "productType": 2, + "productId": 3, + "firmwareVersion": "0.4.4", + "rfRegion": 1, + }, + }, } install_task.cancel() diff --git a/tests/components/zwave_me/test_config_flow.py b/tests/components/zwave_me/test_config_flow.py index 7d1919a8698..145cecd58c8 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 homeassistant.data_entry_flow import FlowResult, FlowResultType 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 f90984e1c7b..f743a2fe96a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,12 +3,13 @@ from __future__ import annotations 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 e30aaa6e0d9..a251b20b0f4 100644 --- a/tests/helpers/test_dispatcher.py +++ b/tests/helpers/test_dispatcher.py @@ -151,3 +151,25 @@ async def test_callback_exception_gets_logged( 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 20bea6a98eb..61ee38a66a7 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -27,8 +27,10 @@ from tests.common import ( MockConfigEntry, MockEntity, MockEntityPlatform, + MockModule, MockPlatform, get_test_home_assistant, + mock_integration, mock_registry, ) @@ -776,7 +778,7 @@ async def test_warn_slow_write_state_custom_component( 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 @@ -795,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, }, } @@ -1505,3 +1505,57 @@ async def test_invalid_state( 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_integration_platform.py b/tests/helpers/test_integration_platform.py index 2dfc0742e26..ed6edcc3690 100644 --- a/tests/helpers/test_integration_platform.py +++ b/tests/helpers/test_integration_platform.py @@ -5,7 +5,6 @@ import pytest 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 test_process_integration_platforms(hass: HomeAssistant) -> None: 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_script.py b/tests/helpers/test_script.py index 5163dd0ca6d..8e4409daa54 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -1053,6 +1053,7 @@ async def test_multiple_runs_wait(hass: HomeAssistant, action_type) -> None: hass.states.async_set("switch.test", "on") hass.async_create_task(script_obj.async_run(context=Context())) await asyncio.wait_for(wait_started_flag.wait(), 1) + await asyncio.sleep(0) assert script_obj.is_running assert len(events) == 1 @@ -1062,6 +1063,7 @@ async def test_multiple_runs_wait(hass: HomeAssistant, action_type) -> None: wait_started_flag.clear() hass.async_create_task(script_obj.async_run()) await asyncio.wait_for(wait_started_flag.wait(), 1) + await asyncio.sleep(0) except (AssertionError, asyncio.TimeoutError): await script_obj.async_stop() raise @@ -4079,6 +4081,7 @@ async def test_script_mode_2( hass.states.async_set("switch.test", "on") hass.async_create_task(script_obj.async_run(context=Context())) await asyncio.wait_for(wait_started_flag.wait(), 1) + await asyncio.sleep(0) assert script_obj.is_running assert len(events) == 1 @@ -4089,6 +4092,7 @@ async def test_script_mode_2( wait_started_flag.clear() hass.async_create_task(script_obj.async_run(context=Context())) await asyncio.wait_for(wait_started_flag.wait(), 1) + await asyncio.sleep(0) assert script_obj.is_running assert len(events) == 2 diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index 803a57e12ed..03a8b5e11b2 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/pylint/conftest.py b/tests/pylint/conftest.py index e8748434350..03f637a646f 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 @@ import pytest 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 00000000000..5e2861b1c74 --- /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 9433eb1649c..9209654a607 100644 --- a/tests/syrupy.py +++ b/tests/syrupy.py @@ -85,6 +85,7 @@ class HomeAssistantSnapshotSerializer(AmberDataSerializer): *, 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 @@ class HomeAssistantSnapshotSerializer(AmberDataSerializer): serializable_data, depth=depth, exclude=exclude, + include=include, matcher=matcher, path=path, visited=visited, @@ -156,7 +158,6 @@ class HomeAssistantSnapshotSerializer(AmberDataSerializer): ) if serialized["via_device_id"] is not None: serialized["via_device_id"] = ANY - serialized.pop("_json_repr") return serialized @classmethod @@ -164,7 +165,7 @@ class HomeAssistantSnapshotSerializer(AmberDataSerializer): 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 @@ class HomeAssistantSnapshotSerializer(AmberDataSerializer): "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 760c7138c88..52caa1ae275 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 8ec4dad2ebd..7cafadb638c 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -15,6 +15,7 @@ from typing import Any 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 test_chained_logging_misses_log_timeout( 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: diff --git a/tests/test_data_entry_flow.py b/tests/test_data_entry_flow.py index 168f97ba779..e6a28fc2e4f 100644 --- a/tests/test_data_entry_flow.py +++ b/tests/test_data_entry_flow.py @@ -344,14 +344,20 @@ async def test_show_progress(hass: HomeAssistant, manager) -> None: VERSION = 5 data = None task_one_done = False + task_two_done = False async def async_step_init(self, user_input=None): - if not user_input: - if not self.task_one_done: + if user_input and "task_finished" in user_input: + if user_input["task_finished"] == 1: self.task_one_done = True - progress_action = "task_one" - else: - progress_action = "task_two" + elif user_input["task_finished"] == 2: + self.task_two_done = True + + if not self.task_one_done: + progress_action = "task_one" + elif not self.task_two_done: + progress_action = "task_two" + if not self.task_one_done or not self.task_two_done: return self.async_show_progress( step_id="init", progress_action=progress_action, @@ -376,7 +382,7 @@ async def test_show_progress(hass: HomeAssistant, manager) -> None: # Mimic task one done and moving to task two # Called by integrations: `hass.config_entries.flow.async_configure(…)` - result = await manager.async_configure(result["flow_id"]) + result = await manager.async_configure(result["flow_id"], {"task_finished": 1}) assert result["type"] == data_entry_flow.FlowResultType.SHOW_PROGRESS assert result["progress_action"] == "task_two" @@ -388,13 +394,20 @@ async def test_show_progress(hass: HomeAssistant, manager) -> None: "refresh": True, } + # Frontend refreshes the flow + result = await manager.async_configure(result["flow_id"]) + assert result["type"] == data_entry_flow.FlowResultType.SHOW_PROGRESS + assert result["progress_action"] == "task_two" + # Mimic task two done and continuing step # Called by integrations: `hass.config_entries.flow.async_configure(…)` - result = await manager.async_configure(result["flow_id"], {"title": "Hello"}) + result = await manager.async_configure( + result["flow_id"], {"task_finished": 2, "title": "Hello"} + ) assert result["type"] == data_entry_flow.FlowResultType.SHOW_PROGRESS_DONE await hass.async_block_till_done() - assert len(events) == 2 + assert len(events) == 2 # 1 for task one and 1 for task two assert events[1].data == { "handler": "test", "flow_id": result["flow_id"], @@ -407,6 +420,93 @@ async def test_show_progress(hass: HomeAssistant, manager) -> None: assert result["title"] == "Hello" +async def test_show_progress_fires_only_when_changed( + hass: HomeAssistant, manager +) -> None: + """Test show progress change logic.""" + manager.hass = hass + + @manager.mock_reg_handler("test") + class TestFlow(data_entry_flow.FlowHandler): + VERSION = 5 + data = None + + async def async_step_init(self, user_input=None): + if user_input: + progress_action = user_input["progress_action"] + description_placeholders = user_input["description_placeholders"] + return self.async_show_progress( + step_id="init", + progress_action=progress_action, + description_placeholders=description_placeholders, + ) + return self.async_show_progress(step_id="init", progress_action="task_one") + + async def async_step_finish(self, user_input=None): + return self.async_create_entry(title=self.data["title"], data=self.data) + + events = async_capture_events( + hass, data_entry_flow.EVENT_DATA_ENTRY_FLOW_PROGRESSED + ) + + async def test_change( + flow_id, + events, + progress_action, + description_placeholders_progress, + number_of_events, + is_change, + ) -> None: + # Called by integrations: `hass.config_entries.flow.async_configure(…)` + result = await manager.async_configure( + flow_id, + { + "progress_action": progress_action, + "description_placeholders": { + "progress": description_placeholders_progress + }, + }, + ) + assert result["type"] == data_entry_flow.FlowResultType.SHOW_PROGRESS + assert result["progress_action"] == progress_action + assert ( + result["description_placeholders"]["progress"] + == description_placeholders_progress + ) + + await hass.async_block_till_done() + assert len(events) == number_of_events + if is_change: + assert events[number_of_events - 1].data == { + "handler": "test", + "flow_id": result["flow_id"], + "refresh": True, + } + + result = await manager.async_init("test") + assert result["type"] == data_entry_flow.FlowResultType.SHOW_PROGRESS + assert result["progress_action"] == "task_one" + assert len(manager.async_progress()) == 1 + assert len(manager.async_progress_by_handler("test")) == 1 + assert manager.async_get(result["flow_id"])["handler"] == "test" + + # Mimic task one tests + await test_change( + result["flow_id"], events, "task_one", 0, 1, True + ) # change (progress action) + await test_change(result["flow_id"], events, "task_one", 0, 1, False) # no change + await test_change( + result["flow_id"], events, "task_one", 25, 2, True + ) # change (description placeholder) + await test_change( + result["flow_id"], events, "task_two", 50, 3, True + ) # change (progress action and description placeholder) + await test_change(result["flow_id"], events, "task_two", 50, 3, False) # no change + await test_change( + result["flow_id"], events, "task_two", 100, 4, True + ) # change (description placeholder) + + async def test_abort_flow_exception(manager) -> None: """Test that the AbortFlow exception works.""" diff --git a/tests/test_loader.py b/tests/test_loader.py index 6e62be08f66..b62e25b79e3 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.""" diff --git a/tests/util/test_aiohttp.py b/tests/util/test_aiohttp.py index ebcc9cec526..76394b42491 100644 --- a/tests/util/test_aiohttp.py +++ b/tests/util/test_aiohttp.py @@ -12,12 +12,19 @@ async def test_request_json() -> None: async def test_request_text() -> None: - """Test a JSON request.""" + """Test bytes in request.""" request = aiohttp.MockRequest(b"hello", status=201, mock_source="test") + assert request.body_exists assert request.status == 201 assert await request.text() == "hello" +async def test_request_body_exists() -> None: + """Test body exists.""" + request = aiohttp.MockRequest(b"", mock_source="test") + assert not request.body_exists + + async def test_request_post_query() -> None: """Test a JSON request.""" request = aiohttp.MockRequest(